Skip to content

Commit c6c1bb1

Browse files
committed
fix(notion-fetch): improve terminal summary validation and child page resolution
1 parent e4b73b8 commit c6c1bb1

File tree

4 files changed

+429
-40
lines changed

4 files changed

+429
-40
lines changed

api-server/fetch-job-runner.test.ts

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ function createSpawnSuccessProcess(options?: {
8888
candidateIds?: string[];
8989
pagesProcessed?: number;
9090
}) {
91+
return createSpawnProcess(
92+
JSON.stringify({
93+
candidateIds: options?.candidateIds ?? [],
94+
pagesProcessed: options?.pagesProcessed ?? 1,
95+
})
96+
);
97+
}
98+
99+
function createSpawnProcess(outputLine: string, exitCode = 0) {
91100
const stdout = new EventEmitter();
92101
const stderr = new EventEmitter();
93102
const child = new EventEmitter() as EventEmitter & {
@@ -97,13 +106,9 @@ function createSpawnSuccessProcess(options?: {
97106
child.stdout = stdout;
98107
child.stderr = stderr;
99108

100-
const summary = JSON.stringify({
101-
candidateIds: options?.candidateIds ?? [],
102-
pagesProcessed: options?.pagesProcessed ?? 1,
103-
});
104109
queueMicrotask(() => {
105-
stdout.emit("data", Buffer.from("Progress: 1/1\n" + summary + "\n"));
106-
child.emit("close", 0);
110+
stdout.emit("data", Buffer.from("Progress: 1/1\n" + outputLine + "\n"));
111+
child.emit("close", exitCode);
107112
});
108113

109114
return child;
@@ -312,4 +317,131 @@ describe("fetch-job-runner", () => {
312317
expect(mockPrepareContentBranchForFetch).not.toHaveBeenCalled();
313318
expect(mockNotionPagesUpdate).not.toHaveBeenCalled();
314319
});
320+
321+
it("fails when terminal JSON summary is missing", async () => {
322+
mockSpawn.mockImplementation(() =>
323+
createSpawnProcess("Progress complete without summary")
324+
);
325+
326+
const result = await runFetchJob({
327+
type: "fetch-ready",
328+
jobId: "job-missing-json",
329+
options: {},
330+
onProgress: vi.fn(),
331+
logger: createLogger(),
332+
childEnv: process.env,
333+
signal: new AbortController().signal,
334+
timeoutMs: 20 * 60 * 1000,
335+
});
336+
337+
expect(result.success).toBe(false);
338+
expect(result.terminal.error?.code).toBe("CONTENT_GENERATION_FAILED");
339+
expect(result.terminal.pagesProcessed).toBe(0);
340+
expect(mockPrepareContentBranchForFetch).not.toHaveBeenCalled();
341+
});
342+
343+
it("fails when terminal JSON summary is inconsistent for fetch-ready", async () => {
344+
mockSpawn.mockImplementation(() =>
345+
createSpawnSuccessProcess({
346+
pagesProcessed: 0,
347+
candidateIds: ["page-1"],
348+
})
349+
);
350+
351+
const result = await runFetchJob({
352+
type: "fetch-ready",
353+
jobId: "job-inconsistent-summary",
354+
options: {},
355+
onProgress: vi.fn(),
356+
logger: createLogger(),
357+
childEnv: process.env,
358+
signal: new AbortController().signal,
359+
timeoutMs: 20 * 60 * 1000,
360+
});
361+
362+
expect(result.success).toBe(false);
363+
expect(result.terminal.error?.code).toBe("CONTENT_GENERATION_FAILED");
364+
expect(result.terminal.pagesProcessed).toBe(0);
365+
expect(mockPrepareContentBranchForFetch).not.toHaveBeenCalled();
366+
});
367+
368+
it("passes fetch-ready status filter args to generation script", async () => {
369+
const result = await runFetchJob({
370+
type: "fetch-ready",
371+
jobId: "job-ready-status-filter",
372+
options: { dryRun: true },
373+
onProgress: vi.fn(),
374+
logger: createLogger(),
375+
childEnv: process.env,
376+
signal: new AbortController().signal,
377+
timeoutMs: 20 * 60 * 1000,
378+
});
379+
380+
expect(result.success).toBe(true);
381+
expect(mockSpawn).toHaveBeenCalledTimes(1);
382+
expect(mockSpawn.mock.calls[0]?.[0]).toBe("bun");
383+
expect(mockSpawn.mock.calls[0]?.[1]).toEqual(
384+
expect.arrayContaining([
385+
"--status-filter",
386+
NOTION_PROPERTIES.READY_TO_PUBLISH,
387+
])
388+
);
389+
expect(mockSpawn.mock.calls[0]?.[1]).toEqual(
390+
expect.arrayContaining(["--status-filter", "Ready to publish"])
391+
);
392+
});
393+
394+
it("transitions all fetch-ready candidates on happy path", async () => {
395+
const candidateIds = ["page-1", "page-2", "page-3"];
396+
mockSpawn.mockImplementation(() =>
397+
createSpawnSuccessProcess({
398+
pagesProcessed: candidateIds.length,
399+
candidateIds,
400+
})
401+
);
402+
403+
const result = await runFetchJob({
404+
type: "fetch-ready",
405+
jobId: "job-ready-happy-path",
406+
options: {},
407+
onProgress: vi.fn(),
408+
logger: createLogger(),
409+
childEnv: process.env,
410+
signal: new AbortController().signal,
411+
timeoutMs: 20 * 60 * 1000,
412+
});
413+
414+
expect(result.success).toBe(true);
415+
expect(result.terminal.pagesTransitioned).toBe(candidateIds.length);
416+
expect(result.terminal.failedPageIds).toEqual([]);
417+
expect(mockNotionPagesUpdate).toHaveBeenCalledTimes(candidateIds.length);
418+
for (const pageId of candidateIds) {
419+
expect(mockNotionPagesUpdate).toHaveBeenCalledWith(
420+
expect.objectContaining({
421+
page_id: pageId,
422+
})
423+
);
424+
}
425+
expect(mockVerifyRemoteHeadMatchesLocal).toHaveBeenCalledTimes(1);
426+
});
427+
428+
it("passes --force and --dry-run through to generation script", async () => {
429+
const result = await runFetchJob({
430+
type: "fetch-ready",
431+
jobId: "job-force-dry-run",
432+
options: { force: true, dryRun: true },
433+
onProgress: vi.fn(),
434+
logger: createLogger(),
435+
childEnv: process.env,
436+
signal: new AbortController().signal,
437+
timeoutMs: 20 * 60 * 1000,
438+
});
439+
440+
expect(result.success).toBe(true);
441+
expect(mockSpawn).toHaveBeenCalledTimes(1);
442+
expect(mockSpawn.mock.calls[0]?.[0]).toBe("bun");
443+
expect(mockSpawn.mock.calls[0]?.[1]).toEqual(
444+
expect.arrayContaining(["--force", "--dry-run"])
445+
);
446+
});
315447
});

api-server/fetch-job-runner.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ async function runGenerationScript(
207207
if (options.maxPages !== undefined && options.maxPages > 0) {
208208
args.push("--max-pages", String(options.maxPages));
209209
}
210+
if (options.force) {
211+
args.push("--force");
212+
}
213+
if (options.dryRun) {
214+
args.push("--dry-run");
215+
}
210216

211217
return await new Promise<string>((resolve, reject) => {
212218
const child = spawn("bun", args, {
@@ -342,6 +348,72 @@ async function runGenerationScript(
342348
});
343349
}
344350

351+
function parseTerminalSummary(
352+
output: string,
353+
type: "fetch-ready" | "fetch-all"
354+
): { pagesProcessed: number; candidateIds: string[] } {
355+
const parsedOutput = extractLastJsonLine(output) as {
356+
candidateIds?: unknown;
357+
pagesProcessed?: unknown;
358+
} | null;
359+
360+
if (!parsedOutput || typeof parsedOutput !== "object") {
361+
throw new ContentRepoError(
362+
"Generation command did not produce a terminal JSON summary",
363+
undefined,
364+
"CONTENT_GENERATION_FAILED"
365+
);
366+
}
367+
368+
const pagesProcessed = parsedOutput.pagesProcessed;
369+
if (
370+
typeof pagesProcessed !== "number" ||
371+
!Number.isFinite(pagesProcessed) ||
372+
!Number.isInteger(pagesProcessed) ||
373+
pagesProcessed < 0
374+
) {
375+
throw new ContentRepoError(
376+
"Generation command produced invalid pagesProcessed in terminal JSON summary",
377+
undefined,
378+
"CONTENT_GENERATION_FAILED"
379+
);
380+
}
381+
382+
const rawCandidateIds = parsedOutput.candidateIds;
383+
const candidateIds =
384+
rawCandidateIds === undefined
385+
? []
386+
: Array.isArray(rawCandidateIds) &&
387+
rawCandidateIds.every((id) => typeof id === "string")
388+
? rawCandidateIds
389+
: null;
390+
391+
if (candidateIds === null) {
392+
throw new ContentRepoError(
393+
"Generation command produced invalid candidateIds in terminal JSON summary",
394+
undefined,
395+
"CONTENT_GENERATION_FAILED"
396+
);
397+
}
398+
399+
if (
400+
type === "fetch-ready" &&
401+
candidateIds.length > 0 &&
402+
pagesProcessed === 0
403+
) {
404+
throw new ContentRepoError(
405+
"Generation summary is inconsistent: candidateIds present but pagesProcessed is zero",
406+
undefined,
407+
"CONTENT_GENERATION_FAILED"
408+
);
409+
}
410+
411+
return {
412+
pagesProcessed,
413+
candidateIds,
414+
};
415+
}
416+
345417
async function getPageStatus(
346418
pageId: string,
347419
signal: AbortSignal,
@@ -431,13 +503,10 @@ export async function runFetchJob({
431503
timeoutMs
432504
);
433505

434-
const parsedOutput = extractLastJsonLine(output) as {
435-
candidateIds?: string[];
436-
pagesProcessed?: number;
437-
} | null;
438-
terminal.pagesProcessed = parsedOutput?.pagesProcessed ?? 0;
506+
const summary = parseTerminalSummary(output, type);
507+
terminal.pagesProcessed = summary.pagesProcessed;
439508
const transitionCandidates: string[] =
440-
type === "fetch-ready" ? (parsedOutput?.candidateIds ?? []) : [];
509+
type === "fetch-ready" ? summary.candidateIds : [];
441510

442511
if (terminal.pagesProcessed === 0) {
443512
if (options.dryRun) {

0 commit comments

Comments
 (0)