Skip to content

Commit f9a1dc7

Browse files
committed
feat(workflow): persist replay provenance
1 parent d5b00f5 commit f9a1dc7

10 files changed

Lines changed: 249 additions & 27 deletions

src/db/migrations.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const migrations: Migration[] = [
3232
name: "workflow-journal",
3333
up: migrateWorkflowJournal,
3434
},
35+
{
36+
version: 6,
37+
name: "workflow-replay-provenance",
38+
up: migrateWorkflowReplayProvenance,
39+
},
3540
];
3641

3742
export function migrateDatabase(sqlite: Database.Database): void {
@@ -282,9 +287,24 @@ function migrateWorkflowJournal(sqlite: Database.Database): void {
282287
`);
283288
}
284289

290+
function migrateWorkflowReplayProvenance(sqlite: Database.Database): void {
291+
addColumnIfMissing(sqlite, "workflow_agent_calls", "prompt", "text not null default ''");
292+
addColumnIfMissing(sqlite, "workflow_agent_calls", "schema_json", "text");
293+
addColumnIfMissing(sqlite, "workflow_agent_calls", "error_kind", "text");
294+
addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_match", "text");
295+
addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_run_id", "text");
296+
addColumnIfMissing(sqlite, "workflow_agent_calls", "replayed_from_call_index", "integer");
297+
addColumnIfMissing(sqlite, "workflow_agent_calls", "replay_reason", "text");
298+
299+
sqlite.exec(`
300+
create index if not exists workflow_agent_calls_replay_source_idx
301+
on workflow_agent_calls(replayed_from_run_id, replayed_from_call_index);
302+
`);
303+
}
304+
285305
function addColumnIfMissing(
286306
sqlite: Database.Database,
287-
table: "workspace_sessions" | "local_agent_sessions",
307+
table: "workspace_sessions" | "local_agent_sessions" | "workflow_agent_calls",
288308
column: string,
289309
definition: string,
290310
): void {

src/db/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ export const workflowAgentCalls = sqliteTable(
157157
.references(() => workflowRuns.id, { onDelete: "cascade" }),
158158
callIndex: integer("call_index").notNull(),
159159
cacheKey: text("cache_key").notNull(),
160+
prompt: text("prompt").notNull().default(""),
161+
schemaJson: text("schema_json"),
160162
provider: text("provider").notNull(),
161163
model: text("model"),
162164
effort: text("effort"),
@@ -168,6 +170,11 @@ export const workflowAgentCalls = sqliteTable(
168170
responseText: text("response_text"),
169171
structuredJson: text("structured_json"),
170172
error: text("error"),
173+
errorKind: text("error_kind"),
174+
replayMatch: text("replay_match"),
175+
replayedFromRunId: text("replayed_from_run_id"),
176+
replayedFromCallIndex: integer("replayed_from_call_index"),
177+
replayReason: text("replay_reason"),
171178
isolation: text("isolation").notNull().default("shared"),
172179
worktreePath: text("worktree_path"),
173180
dirty: text("dirty"),
@@ -179,6 +186,10 @@ export const workflowAgentCalls = sqliteTable(
179186
(table) => [
180187
primaryKey({ columns: [table.runId, table.callIndex] }),
181188
index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey),
189+
index("workflow_agent_calls_replay_source_idx").on(
190+
table.replayedFromRunId,
191+
table.replayedFromCallIndex,
192+
),
182193
],
183194
);
184195

src/oauth-store.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
4646
{ version: 3, name: "local-agent-sessions" },
4747
{ version: 4, name: "local-agent-effort-rename" },
4848
{ version: 5, name: "workflow-journal" },
49+
{ version: 6, name: "workflow-replay-provenance" },
4950
]);
5051
} finally {
5152
database.close();

src/workflow-api.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
buildAgentCacheKeyInput,
1212
createStubBudget,
1313
type AgentIsolationMode,
14+
type AgentCacheKeyInput,
1415
type AgentOpts,
1516
type AppendWorkflowEventInput,
1617
type WorkflowMeta,
@@ -64,10 +65,30 @@ export interface WorkflowReplayHit {
6465
responseText?: string;
6566
structuredJson?: string;
6667
providerSessionId?: string;
68+
replayMatch: "same_index" | "compatible_key";
69+
replayedFromRunId: string;
70+
replayedFromCallIndex: number;
6771
}
6872

73+
export interface WorkflowReplayMiss {
74+
reason:
75+
| "no_compatible_call"
76+
| "prior_call_not_replayable"
77+
| "compatible_result_consumed"
78+
| "identity_changed";
79+
changedFields?: Array<keyof AgentCacheKeyInput>;
80+
}
81+
82+
export type WorkflowReplayDecision =
83+
| { hit: WorkflowReplayHit; miss?: never }
84+
| { hit?: never; miss: WorkflowReplayMiss };
85+
6986
export interface WorkflowReplay {
70-
match(callIndex: number, cacheKey: string): WorkflowReplayHit | null;
87+
decide(
88+
callIndex: number,
89+
cacheKey: string,
90+
input: AgentCacheKeyInput,
91+
): WorkflowReplayDecision;
7192
}
7293

7394
export interface WorkflowJournal {
@@ -78,13 +99,19 @@ export interface WorkflowJournal {
7899
runId: string;
79100
callIndex: number;
80101
cacheKey: string;
102+
prompt: string;
103+
schemaJson?: string;
81104
provider: LocalAgentProvider;
82105
model?: string;
83106
effort?: string;
84107
label?: string;
85108
phase?: string;
86109
isolation?: AgentIsolationMode;
87110
worktreePath?: string;
111+
replayMatch?: "same_index" | "compatible_key";
112+
replayedFromRunId?: string;
113+
replayedFromCallIndex?: number;
114+
replayReason?: string;
88115
}): unknown;
89116
completeAgentCall(input: {
90117
runId: string;
@@ -100,6 +127,7 @@ export interface WorkflowJournal {
100127
runId: string;
101128
callIndex: number;
102129
error: string;
130+
errorKind?: import("./workflow-types.js").WorkflowErrorKind;
103131
worktreePath?: string;
104132
dirty?: boolean;
105133
}): unknown;
@@ -233,19 +261,24 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
233261
});
234262
const cacheKey = hashCacheKey(cacheKeyInput);
235263

236-
if (deps.replay) {
237-
const hit = deps.replay.match(index, cacheKey);
238-
if (hit) {
264+
const replayDecision = deps.replay?.decide(index, cacheKey, cacheKeyInput);
265+
if (replayDecision?.hit) {
266+
const hit = replayDecision.hit;
239267
deps.journal.beginAgentCall({
240268
runId: deps.runId,
241269
callIndex: index,
242270
cacheKey,
271+
prompt,
272+
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
243273
provider,
244274
model: agentOpts.model,
245275
effort: agentOpts.effort,
246276
label: agentOpts.label,
247277
phase,
248278
isolation,
279+
replayMatch: hit.replayMatch,
280+
replayedFromRunId: hit.replayedFromRunId,
281+
replayedFromCallIndex: hit.replayedFromCallIndex,
249282
});
250283
deps.journal.completeAgentCall({
251284
runId: deps.runId,
@@ -260,10 +293,16 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
260293
type: "agent_call_cached",
261294
phase,
262295
label: agentOpts.label,
263-
data: { callIndex: index, cacheKey, provider },
296+
data: {
297+
callIndex: index,
298+
cacheKey,
299+
provider,
300+
replayMatch: hit.replayMatch,
301+
replayedFromRunId: hit.replayedFromRunId,
302+
replayedFromCallIndex: hit.replayedFromCallIndex,
303+
},
264304
});
265305
return hit.value;
266-
}
267306
}
268307

269308
await semaphore.acquire(deps.signal);
@@ -300,13 +339,18 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
300339
runId: deps.runId,
301340
callIndex: index,
302341
cacheKey,
342+
prompt,
343+
schemaJson: agentOpts.schema ? JSON.stringify(agentOpts.schema) : undefined,
303344
provider,
304345
model: agentOpts.model,
305346
effort: agentOpts.effort,
306347
label: agentOpts.label,
307348
phase,
308349
isolation,
309350
worktreePath,
351+
replayReason: replayDecision?.miss
352+
? formatReplayMiss(replayDecision.miss)
353+
: undefined,
310354
});
311355
agentCallBegun = true;
312356
deps.journal.appendEvent({
@@ -453,6 +497,7 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
453497
runId: deps.runId,
454498
callIndex: index,
455499
error: message,
500+
errorKind: error instanceof WorkflowEngineError ? error.kind : "internal",
456501
worktreePath,
457502
});
458503
}
@@ -594,6 +639,12 @@ export function createWorkflowApi(deps: WorkflowApiDeps): WorkflowApi {
594639
};
595640
}
596641

642+
function formatReplayMiss(miss: WorkflowReplayMiss): string {
643+
return miss.reason === "identity_changed" && miss.changedFields?.length
644+
? `${miss.reason}:${miss.changedFields.join(",")}`
645+
: miss.reason;
646+
}
647+
597648
/** Test helper: read current ALS phase (undefined outside phase). */
598649
export function getCurrentWorkflowPhase(): string | undefined {
599650
return phaseAls.getStore();

src/workflow-contracts.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ export const workflowEventPayloadSchemas = {
196196
callIndex: z.number().int().nonnegative(),
197197
cacheKey: z.string(),
198198
provider: localAgentProviderSchema,
199+
replayMatch: z.enum(["same_index", "compatible_key"]),
200+
replayedFromRunId: z.string(),
201+
replayedFromCallIndex: z.number().int().nonnegative(),
199202
})
200203
.strict(),
201204
schema_retry: z

src/workflow-replay.test.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function call(
88
): WorkflowAgentCallRecord {
99
return {
1010
runId: "wfr_prior",
11+
prompt: "prompt",
1112
provider: "codex",
1213
status: "completed",
1314
fromCache: false,
@@ -18,14 +19,25 @@ function call(
1819
};
1920
}
2021

22+
function identity(prompt = "prompt") {
23+
return {
24+
prompt,
25+
provider: "codex" as const,
26+
model: null,
27+
effort: null,
28+
schema: null,
29+
isolation: "shared" as const,
30+
};
31+
}
32+
2133
{
2234
const replay = createWorkflowReplay([
2335
call({ callIndex: 0, cacheKey: "k0", responseText: "a" }),
2436
call({ callIndex: 1, cacheKey: "k1", responseText: "b" }),
2537
]);
26-
assert.equal(replay.match(0, "k0")?.value, "a");
27-
assert.equal(replay.match(1, "k1")?.value, "b");
28-
assert.equal(replay.match(2, "k0"), null);
38+
assert.equal(replay.decide(0, "k0", identity()).hit?.value, "a");
39+
assert.equal(replay.decide(1, "k1", identity()).hit?.value, "b");
40+
assert.equal(replay.decide(2, "k0", identity()).miss?.reason, "compatible_result_consumed");
2941
}
3042

3143
{
@@ -35,9 +47,14 @@ function call(
3547
call({ callIndex: 1, cacheKey: "kb", responseText: "B" }),
3648
]);
3749
// new run asks index0 for kb first
38-
assert.equal(replay.match(0, "kb")?.value, "B");
39-
assert.equal(replay.match(1, "ka")?.value, "A");
40-
assert.equal(replay.match(2, "ka"), null);
50+
const reorderedB = replay.decide(0, "kb", identity()).hit;
51+
assert.equal(reorderedB?.value, "B");
52+
assert.equal(reorderedB?.replayMatch, "compatible_key");
53+
assert.equal(replay.decide(1, "ka", identity()).hit?.value, "A");
54+
assert.equal(
55+
replay.decide(2, "ka", identity()).miss?.reason,
56+
"compatible_result_consumed",
57+
);
4158
}
4259

4360
{
@@ -49,7 +66,16 @@ function call(
4966
structuredJson: '{"ok":true}',
5067
}),
5168
]);
52-
assert.deepEqual(replay.match(0, "ks")?.value, { ok: true });
69+
assert.deepEqual(replay.decide(0, "ks", identity()).hit?.value, { ok: true });
70+
}
71+
72+
{
73+
const replay = createWorkflowReplay([
74+
call({ callIndex: 0, cacheKey: "old", prompt: "old prompt", responseText: "a" }),
75+
]);
76+
const miss = replay.decide(0, "new", identity("new prompt")).miss;
77+
assert.equal(miss?.reason, "identity_changed");
78+
assert.deepEqual(miss?.changedFields, ["prompt"]);
5379
}
5480

5581
console.log("workflow-replay.test.ts: ok");

0 commit comments

Comments
 (0)