Skip to content

Commit 606cbda

Browse files
committed
feat(run-engine): resolve completed waitpoints from the cycle record set
Rebuilds CompletedWaitpoint[] from a wait cycle's ordered id list and records, field-for- field equivalent to the existing snapshot hydration, which is what the executor consumes. It iterates the records, never the order. The order holds only batch-indexed ids, so iterating it would drop every index-less wait: each wait.for, each single triggerAndWait and each token. The equivalence suite pins that, and fails on 10 of 12 cases if the iteration is inverted. The coverage check is the fail-loud rule. The id classifier is total and never throws, so an unrecognised shape would otherwise classify as legacy, find no row, and vanish from the resumed run's completed set. An id that no half resolves throws, and so does an id that both halves claim.
1 parent 3d18eba commit 606cbda

3 files changed

Lines changed: 811 additions & 0 deletions

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// The resolver must produce what the executor already consumes, so the oracle is the
2+
// existing hydration and not a hand-written literal. A literal cannot catch a drift in
3+
// enhanceExecutionSnapshotWithWaitpoints itself; this can.
4+
import type { Waitpoint } from "@trigger.dev/database";
5+
import { describe, expect, it } from "vitest";
6+
import { enhanceExecutionSnapshotWithWaitpoints } from "../systems/executionSnapshotSystem.js";
7+
import { buildCompletedWaitpointRecords } from "./completedWaitpointRecords.js";
8+
import { createCompletedWaitpointResolver } from "./completedWaitpointResolver.js";
9+
import type { CompletionEnvelopeSource } from "./types.js";
10+
11+
const COMPLETED_AT = new Date("2026-08-25T00:00:00.000Z");
12+
const RUN_ID = "run_0123456789abcdefghijklm";
13+
const CHILD_RUN_ID = "run_zyxwvutsrqponmlkjihgfe";
14+
const BATCH_ID = "batch_0123456789abcdefghijk";
15+
16+
/**
17+
* One waitpoint, in both shapes, from one description. Keeping them in one factory is what
18+
* makes the comparison meaningful: a field added to only one shape shows up as a diff.
19+
*/
20+
function pair(overrides: {
21+
id: string;
22+
type: Waitpoint["type"];
23+
output?: string | null;
24+
outputType?: string;
25+
outputIsError?: boolean;
26+
completedByTaskRunId?: string | null;
27+
completedByBatchId?: string | null;
28+
completedAfter?: Date | null;
29+
idempotencyKey?: string;
30+
userProvidedIdempotencyKey?: boolean;
31+
inactiveIdempotencyKey?: string | null;
32+
}): { row: Waitpoint; source: CompletionEnvelopeSource } {
33+
const outputType = overrides.outputType ?? "application/json";
34+
const outputIsError = overrides.outputIsError ?? false;
35+
const output = overrides.output ?? null;
36+
const isRef = outputType === "application/store";
37+
38+
const row = {
39+
id: overrides.id,
40+
friendlyId: `waitpoint_${overrides.id}`,
41+
type: overrides.type,
42+
status: "COMPLETED",
43+
completedAt: COMPLETED_AT,
44+
output,
45+
outputType,
46+
outputIsError,
47+
completedByTaskRunId: overrides.completedByTaskRunId ?? null,
48+
completedByBatchId: overrides.completedByBatchId ?? null,
49+
completedAfter: overrides.completedAfter ?? null,
50+
idempotencyKey: overrides.idempotencyKey ?? "internal",
51+
userProvidedIdempotencyKey: overrides.userProvidedIdempotencyKey ?? false,
52+
inactiveIdempotencyKey: overrides.inactiveIdempotencyKey ?? null,
53+
} as unknown as Waitpoint;
54+
55+
const source: CompletionEnvelopeSource = {
56+
id: overrides.id,
57+
friendlyId: `waitpoint_${overrides.id}`,
58+
type: overrides.type,
59+
completedAt: COMPLETED_AT,
60+
outputType,
61+
outputIsError,
62+
...(output !== null ? (isRef ? { outputRef: output } : { output }) : {}),
63+
...(overrides.completedByTaskRunId && {
64+
completedByTaskRunId: overrides.completedByTaskRunId,
65+
}),
66+
...(overrides.completedByBatchId && { completedByBatchId: overrides.completedByBatchId }),
67+
...(overrides.completedAfter && { completedAfter: overrides.completedAfter }),
68+
...(overrides.userProvidedIdempotencyKey &&
69+
!overrides.inactiveIdempotencyKey &&
70+
overrides.idempotencyKey
71+
? { idempotencyKey: overrides.idempotencyKey }
72+
: {}),
73+
};
74+
75+
return { row, source };
76+
}
77+
78+
function snapshot(batchId: string | null) {
79+
return { id: "snap_1", runId: RUN_ID, batchId } as never;
80+
}
81+
82+
function sortEntries<T extends { id: string; index?: number }>(entries: T[]): T[] {
83+
return [...entries].sort((a, b) => a.id.localeCompare(b.id) || (a.index ?? -1) - (b.index ?? -1));
84+
}
85+
86+
/**
87+
* Run one description through both paths and assert the results match.
88+
*
89+
* `deriveFromRun` is the one case where the two paths cannot be identical by construction:
90+
* the row carries the value and the record carries a marker. Feeding the row's own output
91+
* back as the run's output is what makes them comparable, which is exactly the claim the
92+
* variant makes — that TaskRun.output holds the same string.
93+
*/
94+
async function bothPaths(
95+
pairs: ReturnType<typeof pair>[],
96+
order: string[],
97+
batchId: string | null = null
98+
) {
99+
const outputsByRunId = new Map<string, string>();
100+
for (const { row } of pairs) {
101+
if (row.completedByTaskRunId && row.output !== null) {
102+
outputsByRunId.set(row.completedByTaskRunId, row.output);
103+
}
104+
}
105+
106+
const expected = enhanceExecutionSnapshotWithWaitpoints(
107+
snapshot(batchId),
108+
pairs.map((p) => p.row),
109+
order
110+
).completedWaitpoints;
111+
112+
const actual = await createCompletedWaitpointResolver({
113+
readRunOutput: async (taskRunId) => outputsByRunId.get(taskRunId),
114+
})({
115+
runId: RUN_ID,
116+
...(batchId ? { batchId } : {}),
117+
pointer: { cycleSeq: 1, count: order.length },
118+
order,
119+
records: buildCompletedWaitpointRecords(pairs.map((p) => p.source)),
120+
});
121+
122+
return { expected: sortEntries(expected), actual: sortEntries(actual) };
123+
}
124+
125+
describe("the resolver reproduces the existing hydration", () => {
126+
it("for a single MANUAL waitpoint with an inline output", async () => {
127+
const { expected, actual } = await bothPaths(
128+
[pair({ id: "wp_manual", type: "MANUAL", output: '{"token":1}' })],
129+
[]
130+
);
131+
132+
expect(actual).toEqual(expected);
133+
});
134+
135+
it("for a MANUAL waitpoint with a user-provided idempotency key", async () => {
136+
const { expected, actual } = await bothPaths(
137+
[
138+
pair({
139+
id: "wp_manual",
140+
type: "MANUAL",
141+
output: '{"token":1}',
142+
idempotencyKey: "user-key",
143+
userProvidedIdempotencyKey: true,
144+
}),
145+
],
146+
[]
147+
);
148+
149+
expect(actual).toEqual(expected);
150+
expect(actual[0]?.idempotencyKey).toBe("user-key");
151+
});
152+
153+
it("for an idempotency key the user provided but that went inactive", async () => {
154+
const { expected, actual } = await bothPaths(
155+
[
156+
pair({
157+
id: "wp_manual",
158+
type: "MANUAL",
159+
output: '{"token":1}',
160+
idempotencyKey: "user-key",
161+
userProvidedIdempotencyKey: true,
162+
inactiveIdempotencyKey: "old",
163+
}),
164+
],
165+
[]
166+
);
167+
168+
expect(actual).toEqual(expected);
169+
expect(actual[0]?.idempotencyKey).toBeUndefined();
170+
});
171+
172+
it("for a DATETIME waitpoint", async () => {
173+
const { expected, actual } = await bothPaths(
174+
[
175+
pair({
176+
id: "wp_datetime",
177+
type: "DATETIME",
178+
completedAfter: new Date("2026-08-26T00:00:00.000Z"),
179+
}),
180+
],
181+
[]
182+
);
183+
184+
expect(actual).toEqual(expected);
185+
});
186+
187+
it("for a RUN waitpoint outside a batch", async () => {
188+
const { expected, actual } = await bothPaths(
189+
[
190+
pair({
191+
id: "wp_run",
192+
type: "RUN",
193+
output: '{"ok":true}',
194+
completedByTaskRunId: CHILD_RUN_ID,
195+
}),
196+
],
197+
[]
198+
);
199+
200+
expect(actual).toEqual(expected);
201+
});
202+
203+
it("for a RUN waitpoint read under a batch", async () => {
204+
const { expected, actual } = await bothPaths(
205+
[
206+
pair({
207+
id: "wp_run",
208+
type: "RUN",
209+
output: '{"ok":true}',
210+
completedByTaskRunId: CHILD_RUN_ID,
211+
}),
212+
],
213+
["wp_run"],
214+
BATCH_ID
215+
);
216+
217+
expect(actual).toEqual(expected);
218+
expect(actual[0]?.completedByTaskRun?.batch?.id).toBe(BATCH_ID);
219+
});
220+
221+
it("for a RUN waitpoint whose output is an error", async () => {
222+
const { expected, actual } = await bothPaths(
223+
[
224+
pair({
225+
id: "wp_run",
226+
type: "RUN",
227+
output: '{"message":"boom"}',
228+
outputIsError: true,
229+
completedByTaskRunId: CHILD_RUN_ID,
230+
}),
231+
],
232+
[]
233+
);
234+
235+
expect(actual).toEqual(expected);
236+
});
237+
238+
it("for a BATCH waitpoint", async () => {
239+
const { expected, actual } = await bothPaths(
240+
[pair({ id: "wp_batch", type: "BATCH", completedByBatchId: BATCH_ID })],
241+
[]
242+
);
243+
244+
expect(actual).toEqual(expected);
245+
});
246+
247+
it("for an already-offloaded output", async () => {
248+
const { expected, actual } = await bothPaths(
249+
[
250+
pair({
251+
id: "wp_manual",
252+
type: "MANUAL",
253+
output: "store-key-1",
254+
outputType: "application/store",
255+
}),
256+
],
257+
[]
258+
);
259+
260+
expect(actual).toEqual(expected);
261+
});
262+
263+
it("for one run present at two batch indexes", async () => {
264+
const { expected, actual } = await bothPaths(
265+
[
266+
pair({
267+
id: "wp_run",
268+
type: "RUN",
269+
output: '{"ok":true}',
270+
completedByTaskRunId: CHILD_RUN_ID,
271+
}),
272+
],
273+
["wp_run", "wp_run"],
274+
BATCH_ID
275+
);
276+
277+
expect(actual).toEqual(expected);
278+
expect(actual.map((w) => w.index)).toEqual([0, 1]);
279+
});
280+
281+
it("for an index-less waitpoint sitting beside indexed ones", async () => {
282+
const { expected, actual } = await bothPaths(
283+
[
284+
pair({ id: "wp_indexless", type: "MANUAL", output: '{"token":1}' }),
285+
pair({
286+
id: "wp_run",
287+
type: "RUN",
288+
output: '{"ok":true}',
289+
completedByTaskRunId: CHILD_RUN_ID,
290+
}),
291+
],
292+
["wp_run"],
293+
BATCH_ID
294+
);
295+
296+
expect(actual).toEqual(expected);
297+
expect(actual.find((w) => w.id === "wp_indexless")?.index).toBeUndefined();
298+
});
299+
300+
it("for every type at once, under a batch", async () => {
301+
const { expected, actual } = await bothPaths(
302+
[
303+
pair({
304+
id: "wp_run",
305+
type: "RUN",
306+
output: '{"ok":true}',
307+
completedByTaskRunId: CHILD_RUN_ID,
308+
}),
309+
pair({ id: "wp_batch", type: "BATCH", completedByBatchId: BATCH_ID }),
310+
pair({
311+
id: "wp_datetime",
312+
type: "DATETIME",
313+
completedAfter: new Date("2026-08-26T00:00:00.000Z"),
314+
}),
315+
pair({
316+
id: "wp_manual",
317+
type: "MANUAL",
318+
output: '{"token":1}',
319+
idempotencyKey: "user-key",
320+
userProvidedIdempotencyKey: true,
321+
}),
322+
],
323+
["wp_run", "wp_batch", "wp_datetime"],
324+
BATCH_ID
325+
);
326+
327+
expect(actual).toEqual(expected);
328+
expect(actual).toHaveLength(4);
329+
});
330+
});

0 commit comments

Comments
 (0)