@deksden-com/dd-flow-cli 0.9.0-beta.63 → 0.9.0-beta.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/build-info.json +6 -6
- package/dist/cli/run-cli.js +45 -4
- package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
- package/dist/harness-runtime/lib/process-json.mjs +15 -6
- package/dist/services/cleanup.js +1 -1
- package/dist/services/code-checks.js +1 -1
- package/dist/services/eval-snapshots.js +2 -2
- package/dist/services/harness-adapter.js +61 -9
- package/dist/services/hooks.js +26 -0
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-invocations.js +194 -22
- package/dist/services/managed-processes.js +2 -2
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +1 -1
- package/dist/services/migrations.js +1 -1
- package/dist/services/projects.js +1 -1
- package/dist/services/protocols.js +2 -2
- package/dist/services/run-control-worker.js +3 -3
- package/dist/services/run-control.js +1 -1
- package/dist/services/run-controller-adapter.js +8 -3
- package/dist/services/run-controller-capture.js +2 -2
- package/dist/services/run-controller-process.js +2 -2
- package/dist/services/run-controller-recovery.js +3 -3
- package/dist/services/run-controller.js +297 -180
- package/dist/services/run-recovery-runtime.js +1 -1
- package/dist/services/run-recovery.js +3 -3
- package/dist/services/runs.js +13 -14
- package/dist/services/runtime-budget.js +4 -4
- package/dist/services/runtime-scope-control.js +5 -5
- package/dist/services/runtime-scope-resume.js +7 -7
- package/dist/services/runtime-scope-worker.js +2 -2
- package/dist/services/stage-pause.js +2 -2
- package/dist/services/vnext-code-review.js +9 -4
- package/dist/services/vnext-code.js +10 -3
- package/dist/services/vnext-merge.js +16 -21
- package/dist/services/vnext-plan-review.js +4 -2
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/work-registry.js +198 -121
- package/dist/storage/database.js +112 -20
- package/dist/storage/writer-contract.js +2 -1
- package/dist/storage/writer-migration.js +2 -2
- package/package.json +1 -1
|
@@ -91,15 +91,17 @@ export function assertWorkLifecycleOwner(context, input) {
|
|
|
91
91
|
throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
|
|
92
92
|
}
|
|
93
93
|
export function createChildWork(context, input) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
94
|
+
return context.db.writeTransaction(() => {
|
|
95
|
+
const parent = requireWork(context, input.parentWorkId);
|
|
96
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
97
|
+
if (parent.status !== "running")
|
|
98
|
+
throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
|
|
99
|
+
const id = nextWorkId(context, parent.project_id, input.slug);
|
|
100
|
+
const now = context.now();
|
|
101
|
+
context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
|
|
102
|
+
refreshRunWorkProjection(context, parent.project_id, parent.run_id);
|
|
103
|
+
return requireWork(context, id);
|
|
104
|
+
});
|
|
103
105
|
}
|
|
104
106
|
/** Validate a proposed batch before PLAN accepts it, without registering Work. */
|
|
105
107
|
export function validateWorkBatchFile(file) {
|
|
@@ -122,10 +124,6 @@ export function validateWorkBatchFile(file) {
|
|
|
122
124
|
}
|
|
123
125
|
export function addWorkBatch(context, input) {
|
|
124
126
|
ensureWorkRegistry(context);
|
|
125
|
-
const parent = requireWork(context, input.parentWorkId);
|
|
126
|
-
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
127
|
-
if (parent.status !== "running")
|
|
128
|
-
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
129
127
|
const parsed = readJson(input.file);
|
|
130
128
|
if (!Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
131
129
|
throw new AppError("validation", "work batch requires non-empty works", 2);
|
|
@@ -133,18 +131,21 @@ export function addWorkBatch(context, input) {
|
|
|
133
131
|
const keys = new Set(items.map((item) => item.key));
|
|
134
132
|
if (keys.size !== items.length)
|
|
135
133
|
throw new AppError("validation", "work batch keys must be unique", 2);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
134
|
+
return context.db.writeTransaction(() => {
|
|
135
|
+
const parent = requireWork(context, input.parentWorkId);
|
|
136
|
+
assertRunMutationAllowed(context, parent.project_id, parent.run_id);
|
|
137
|
+
if (parent.status !== "running")
|
|
138
|
+
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
139
|
+
const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
|
|
140
|
+
const existingIds = new Set(existing.map((row) => row.work_id));
|
|
141
|
+
for (const item of items)
|
|
142
|
+
for (const dependency of item.depends_on ?? [])
|
|
143
|
+
if (!keys.has(dependency) && !existingIds.has(dependency))
|
|
144
|
+
throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
|
|
145
|
+
for (const item of items)
|
|
146
|
+
if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
|
|
147
|
+
throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
|
|
148
|
+
const now = context.now();
|
|
148
149
|
const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
|
|
149
150
|
const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
|
|
150
151
|
const resolve = (value) => ids.get(value) ?? value;
|
|
@@ -157,14 +158,8 @@ export function addWorkBatch(context, input) {
|
|
|
157
158
|
for (const item of proposed)
|
|
158
159
|
appendFlowRunTimelineEvent(context, parent.project_id, parent.run_id, { type: "work_materialized", work_id: item.id, parent_work_id: item.parentId, depends_on: item.dependencies, launch_policy: item.launch_policy ?? "reuse_allowed" });
|
|
159
160
|
refreshRunWorkProjection(context, parent.project_id, parent.run_id);
|
|
160
|
-
context.db.exec("RELEASE work_batch");
|
|
161
161
|
return { ok: true, work_ids: Object.fromEntries(ids) };
|
|
162
|
-
}
|
|
163
|
-
catch (error) {
|
|
164
|
-
context.db.exec("ROLLBACK TO work_batch");
|
|
165
|
-
context.db.exec("RELEASE work_batch");
|
|
166
|
-
throw error;
|
|
167
|
-
}
|
|
162
|
+
});
|
|
168
163
|
}
|
|
169
164
|
export function listWorks(context, input) {
|
|
170
165
|
ensureWorkRegistry(context);
|
|
@@ -216,6 +211,7 @@ function receiptIntentPath(resultPath, sessionId) {
|
|
|
216
211
|
function receiptFileHash(file) {
|
|
217
212
|
return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
|
|
218
213
|
}
|
|
214
|
+
function sha256(contents) { return crypto.createHash("sha256").update(contents).digest("hex"); }
|
|
219
215
|
function publishReceiptFile(file, contents) {
|
|
220
216
|
const temporary = `${file}.tmp-${crypto.randomUUID()}`;
|
|
221
217
|
try {
|
|
@@ -240,9 +236,44 @@ function publishReceiptFile(file, contents) {
|
|
|
240
236
|
fs.rmSync(temporary, { force: true });
|
|
241
237
|
}
|
|
242
238
|
}
|
|
239
|
+
/** Rebuild prompt/context projections from a committed Work-start receipt. */
|
|
240
|
+
export function materializePendingWorkStarts(context, projectId, runId) {
|
|
241
|
+
const runHome = requireRunHome(requireRun(context, projectId, runId));
|
|
242
|
+
context.db.writeTransaction(() => {
|
|
243
|
+
const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' AND ws.start_receipt_json IS NOT NULL", [projectId, runId]);
|
|
244
|
+
for (const row of rows) {
|
|
245
|
+
// The same coordinator Session can move on to a different stage packet.
|
|
246
|
+
// A start receipt is not authority to overwrite that later packet.
|
|
247
|
+
let receipt;
|
|
248
|
+
try {
|
|
249
|
+
receipt = JSON.parse(row.start_receipt_json);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
|
|
253
|
+
}
|
|
254
|
+
const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
|
|
255
|
+
if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
|
|
256
|
+
continue;
|
|
257
|
+
if (receipt.version !== 1 || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
|
|
258
|
+
throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
|
|
259
|
+
publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
|
|
260
|
+
publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function publishStartReceiptFile(file, contents, expectedHash) {
|
|
265
|
+
const actual = receiptFileHash(file);
|
|
266
|
+
if (actual === expectedHash)
|
|
267
|
+
return;
|
|
268
|
+
if (actual !== null) {
|
|
269
|
+
throw new AppError("work_start_projection_conflict", "Work start packet differs from its committed receipt; preserve it for recovery", 1, { path: file, expected_sha256: expectedHash, actual_sha256: actual });
|
|
270
|
+
}
|
|
271
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
272
|
+
publishReceiptFile(file, contents);
|
|
273
|
+
}
|
|
243
274
|
/** Replay only a prepared publication whose exact receipt was committed in SQLite. */
|
|
244
275
|
export function materializePendingWorkResults(context, projectId, runId) {
|
|
245
|
-
context.db.
|
|
276
|
+
context.db.beginWriteTransaction();
|
|
246
277
|
try {
|
|
247
278
|
const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL`, [projectId, runId]);
|
|
248
279
|
for (const row of rows) {
|
|
@@ -280,33 +311,37 @@ export function materializePendingWorkResults(context, projectId, runId) {
|
|
|
280
311
|
}
|
|
281
312
|
}
|
|
282
313
|
export function mutateWorkDeps(context, input) {
|
|
283
|
-
|
|
284
|
-
|
|
314
|
+
if (input.action === "list") {
|
|
315
|
+
const work = requireWork(context, input.workId);
|
|
285
316
|
return { work_id: work.work_id, depends_on: parseDependencies(work) };
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
}
|
|
318
|
+
return context.db.writeTransaction(() => {
|
|
319
|
+
const work = requireWork(context, input.workId);
|
|
320
|
+
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
321
|
+
if (work.status !== "created")
|
|
322
|
+
throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
|
|
323
|
+
const dependencies = new Set(parseDependencies(work));
|
|
324
|
+
if (input.action === "clear")
|
|
325
|
+
dependencies.clear();
|
|
326
|
+
for (const id of input.on ?? []) {
|
|
327
|
+
const dependency = requireWork(context, id);
|
|
328
|
+
if (dependency.run_id !== work.run_id)
|
|
329
|
+
throw new AppError("validation", "Dependencies must stay in one RUN", 2);
|
|
330
|
+
if (input.action === "add")
|
|
331
|
+
dependencies.add(dependency.work_id);
|
|
332
|
+
else
|
|
333
|
+
dependencies.delete(dependency.work_id);
|
|
334
|
+
}
|
|
335
|
+
if (dependencies.has(work.work_id))
|
|
336
|
+
throw new AppError("validation", "Work cannot depend on itself", 2);
|
|
337
|
+
assertNoCycles(context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [work.project_id, work.run_id]).map((row) => ({ id: row.work_id, dependencies: row.work_id === work.work_id ? [...dependencies] : parseDependencies(row) })));
|
|
338
|
+
context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
|
|
339
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
340
|
+
return { work_id: work.work_id, depends_on: [...dependencies] };
|
|
341
|
+
});
|
|
307
342
|
}
|
|
308
|
-
export function deleteWork(context, id) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
309
|
-
throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
|
|
343
|
+
export function deleteWork(context, id) { return context.db.writeTransaction(() => { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
|
|
344
|
+
throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }); }
|
|
310
345
|
export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
311
346
|
/**
|
|
312
347
|
* Return the engine-bound command, never a PATH-dependent `dd-flow` token.
|
|
@@ -329,6 +364,15 @@ export function assertWorkLaunchReady(context, id) {
|
|
|
329
364
|
return work;
|
|
330
365
|
}
|
|
331
366
|
export function startWork(context, id, input) {
|
|
367
|
+
if (input.hookEventId && !context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
368
|
+
const retainedWork = requireWork(context, id);
|
|
369
|
+
const retainedRun = requireRun(context, retainedWork.project_id, retainedWork.run_id);
|
|
370
|
+
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(retainedRun.project_root))
|
|
371
|
+
throw new AppError("project_mismatch", "Work start belongs to another project", 1);
|
|
372
|
+
const reply = recoverCommittedWorkStart(context, null, input.hookEventId, recoveryGuard(context, retainedWork.project_id, retainedWork.run_id)?.generation ?? 0, retainedWork.work_id);
|
|
373
|
+
if (reply)
|
|
374
|
+
return reply;
|
|
375
|
+
}
|
|
332
376
|
const work = assertWorkLaunchReady(context, id);
|
|
333
377
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
334
378
|
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
@@ -345,16 +389,9 @@ export function startWork(context, id, input) {
|
|
|
345
389
|
hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
346
390
|
}
|
|
347
391
|
}
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
started = startBoundWork(context, work, run, identity, hookEventId);
|
|
352
|
-
}
|
|
353
|
-
catch (error) {
|
|
354
|
-
context.db.run("UPDATE hook_events SET status = 'observed', claimed_at = NULL WHERE project_id = ? AND event_key = ? AND status = 'claimed'", [work.project_id, hookEventId]);
|
|
355
|
-
throw error;
|
|
356
|
-
}
|
|
357
|
-
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
|
|
392
|
+
const eventKey = hookEventId;
|
|
393
|
+
const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
|
|
394
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
|
|
358
395
|
return started;
|
|
359
396
|
}
|
|
360
397
|
/** Binds the already-running coordinator of a stage to its trusted hook Session. */
|
|
@@ -378,7 +415,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
378
415
|
throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
|
|
379
416
|
const now = context.now();
|
|
380
417
|
const id = `WS-${crypto.randomUUID()}`;
|
|
381
|
-
context.db.
|
|
418
|
+
context.db.beginWriteTransaction();
|
|
382
419
|
try {
|
|
383
420
|
context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
|
|
384
421
|
const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
|
|
@@ -400,7 +437,7 @@ export function bindRunningWorkSession(context, input) {
|
|
|
400
437
|
}
|
|
401
438
|
const now = context.now();
|
|
402
439
|
const id = `WS-${crypto.randomUUID()}`;
|
|
403
|
-
context.db.
|
|
440
|
+
context.db.beginWriteTransaction();
|
|
404
441
|
try {
|
|
405
442
|
bindSession(context, work, run, identity, now);
|
|
406
443
|
context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
|
|
@@ -445,65 +482,105 @@ export function startStageCoordinatorWork(context, input) {
|
|
|
445
482
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
446
483
|
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
447
484
|
throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
|
|
448
|
-
const
|
|
449
|
-
const binding = startBoundWork(context, work, run, identity, input.hookEventId);
|
|
450
|
-
const sessionId = String(binding.session_binding.session_id ?? "");
|
|
451
|
-
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
|
|
485
|
+
const binding = startBoundWork(context, work, run, () => claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) }), input.hookEventId, input.stage);
|
|
452
486
|
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
453
487
|
return binding;
|
|
454
488
|
}
|
|
455
|
-
/**
|
|
456
|
-
export function startBoundWork(context, work, run,
|
|
457
|
-
assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
|
|
489
|
+
/** Claim, command authority and binding commit together; only packet projection follows. */
|
|
490
|
+
export function startBoundWork(context, work, run, claimIdentity, hookEventId, stage) {
|
|
458
491
|
const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
459
|
-
if (work.status !== "created")
|
|
460
|
-
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
461
|
-
if (fs.existsSync(path.join(requireRunHome(run), "works", work.work_id, ".retry.json")))
|
|
462
|
-
throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
|
|
463
|
-
const now = context.now();
|
|
464
|
-
const linkId = `WS-${crypto.randomUUID()}`;
|
|
465
492
|
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const payload = parsePayload(work);
|
|
476
|
-
const readOnly = payload?.read_only === true;
|
|
477
|
-
const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
478
|
-
const token = crypto.randomUUID();
|
|
479
|
-
const promptCandidate = `${promptPath}.${token}.tmp`;
|
|
480
|
-
const contextCandidate = `${contextPath}.${token}.tmp`;
|
|
481
|
-
fs.writeFileSync(promptCandidate, prompt);
|
|
482
|
-
fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
|
|
483
|
-
context.db.exec("BEGIN IMMEDIATE");
|
|
493
|
+
const linkId = `WS-${crypto.randomUUID()}`;
|
|
494
|
+
// Recovery can reopen the same Work without retiring its historical packet.
|
|
495
|
+
// Give each new-generation start its own immutable publication destination.
|
|
496
|
+
const packetDirectory = generation > 0 ? path.join(directory, "starts", linkId) : directory;
|
|
497
|
+
const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
|
|
498
|
+
// The expensive filesystem observation is not performed under the writer lock.
|
|
499
|
+
const readOnly = parsePayload(work)?.read_only === true;
|
|
500
|
+
const fingerprint = readOnly ? workspaceFingerprint(run.workspace_root) : undefined;
|
|
501
|
+
let binding;
|
|
484
502
|
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
503
|
+
binding = context.db.writeTransaction(() => {
|
|
504
|
+
const current = requireWork(context, work.work_id);
|
|
505
|
+
assertRunMutationAllowed(context, current.project_id, current.run_id, generation);
|
|
506
|
+
if (current.status !== "created")
|
|
507
|
+
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
|
|
508
|
+
if (Object.keys(current).some(key => current[key] !== work[key]))
|
|
509
|
+
throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
|
|
510
|
+
if (!isReady(context, current))
|
|
511
|
+
throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
|
|
512
|
+
if (fs.existsSync(path.join(directory, ".retry.json")))
|
|
513
|
+
throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
|
|
514
|
+
const identity = claimIdentity();
|
|
515
|
+
assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
|
|
516
|
+
const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
517
|
+
const recovery = recoveryPromptForWork(context, current.work_id);
|
|
518
|
+
let prompt = renderWorkerPrompt(context, current, run, dependencyResults);
|
|
519
|
+
if (recovery)
|
|
520
|
+
prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
|
|
521
|
+
const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint } : {}) };
|
|
522
|
+
const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
|
|
523
|
+
const response = { ok: true, work_id: current.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
524
|
+
const receipt = JSON.stringify({ version: 1, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
|
|
525
|
+
const now = context.now();
|
|
526
|
+
bindSession(context, current, run, identity, now);
|
|
527
|
+
if (current.parent_work_id && current.launch_policy === "reuse_allowed")
|
|
528
|
+
context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, current.work_id]);
|
|
529
|
+
if (context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, current.work_id]).changes !== 1)
|
|
530
|
+
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
|
|
531
|
+
context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
|
|
532
|
+
if (stage)
|
|
533
|
+
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
|
|
534
|
+
if (recovery)
|
|
535
|
+
activateRecoveryWork(context, current.work_id);
|
|
536
|
+
return response;
|
|
537
|
+
});
|
|
498
538
|
}
|
|
499
539
|
catch (error) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
540
|
+
// This transaction publishes only SQL authority, never files or provider calls.
|
|
541
|
+
if (error instanceof AppError && error.code === "storage_write_failed" && error.details.transaction_rolled_back === true)
|
|
542
|
+
error.details.effect = "no_effect";
|
|
543
|
+
if (error instanceof AppError && error.details.effect === "no_effect" && !context.env.DD_FLOW_CURRENT_INVOCATION) {
|
|
544
|
+
let retry = false;
|
|
545
|
+
try {
|
|
546
|
+
retry = context.db.writeTransaction(() => {
|
|
547
|
+
if (context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.work_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.storage_retry') = 1", [work.project_id, work.work_id, generation]))
|
|
548
|
+
return false;
|
|
549
|
+
return context.db.run("UPDATE hook_events SET outcome_json = ? WHERE project_id = ? AND event_key = ? AND status = 'observed' AND outcome_json IS NULL", [JSON.stringify({ work_id: work.work_id, run_id: work.run_id, generation, effect: "no_effect", disposition: "retrying", storage_retry: 1, deadline: Date.now() + 60000 }), work.project_id, hookEventId]).changes === 1;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (persistence) {
|
|
553
|
+
error.details.retry_persistence_error = String(persistence);
|
|
554
|
+
}
|
|
555
|
+
if (retry)
|
|
556
|
+
return startBoundWork(context, work, run, claimIdentity, hookEventId, stage);
|
|
557
|
+
}
|
|
503
558
|
throw error;
|
|
504
559
|
}
|
|
505
|
-
|
|
506
|
-
|
|
560
|
+
try {
|
|
561
|
+
materializePendingWorkStarts(context, work.project_id, work.run_id);
|
|
562
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (error instanceof AppError) {
|
|
566
|
+
error.details.effect = "committed";
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
throw new AppError("work_start_publication_failed", error instanceof Error ? error.message : String(error), 1, { effect: "committed", work_id: work.work_id, hook_event_id: hookEventId, cause: { code: error?.code ?? null } });
|
|
570
|
+
}
|
|
571
|
+
return binding;
|
|
572
|
+
}
|
|
573
|
+
/** Read the exact committed reply, never bind another Session or regenerate a packet. */
|
|
574
|
+
export function recoverCommittedWorkStart(context, invocationId, eventKey, generation, workId) {
|
|
575
|
+
const row = context.db.get("SELECT ws.start_receipt_json, w.project_id, w.run_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE ws.start_receipt_json IS NOT NULL AND ws.hook_event_id = ? AND json_extract(ws.start_receipt_json, '$.invocation_id') IS ? AND (? IS NULL OR w.work_id = ?)", [eventKey, invocationId, workId ?? null, workId ?? null]);
|
|
576
|
+
if (!row)
|
|
577
|
+
return undefined;
|
|
578
|
+
const receipt = JSON.parse(row.start_receipt_json);
|
|
579
|
+
if (receipt.generation !== generation || !receipt.response || receipt.response_sha256 !== sha256(JSON.stringify(receipt.response)))
|
|
580
|
+
throw new AppError("work_start_receipt_invalid", "Committed reply identity or checksum differs", 1, { invocation_id: invocationId });
|
|
581
|
+
assertRunMutationAllowed(context, row.project_id, row.run_id, generation);
|
|
582
|
+
materializePendingWorkStarts(context, row.project_id, row.run_id);
|
|
583
|
+
return receipt.response;
|
|
507
584
|
}
|
|
508
585
|
/** Root coordinators have no worker checks: settle them inside the stage transaction. */
|
|
509
586
|
export function finishStageCoordinatorWork(context, id, result) {
|
|
@@ -532,7 +609,7 @@ export function retryWork(context, id, reason) {
|
|
|
532
609
|
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
533
610
|
const journal = path.join(directory, ".retry.json");
|
|
534
611
|
let archive = "";
|
|
535
|
-
context.db.
|
|
612
|
+
context.db.beginWriteTransaction();
|
|
536
613
|
try {
|
|
537
614
|
assertRunMutationAllowed(context, work.project_id, work.run_id);
|
|
538
615
|
const current = requireWork(context, work.work_id);
|
|
@@ -637,7 +714,7 @@ async function settle(context, id, status, result, progress, retry) {
|
|
|
637
714
|
}
|
|
638
715
|
}
|
|
639
716
|
const now = context.now();
|
|
640
|
-
context.db.
|
|
717
|
+
context.db.beginWriteTransaction();
|
|
641
718
|
try {
|
|
642
719
|
assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
|
|
643
720
|
if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
|
package/dist/storage/database.js
CHANGED
|
@@ -147,6 +147,7 @@ export function ensureVnextWorkStorage(db) {
|
|
|
147
147
|
status TEXT NOT NULL,
|
|
148
148
|
prompt_path TEXT NOT NULL,
|
|
149
149
|
result_path TEXT,
|
|
150
|
+
start_receipt_json TEXT,
|
|
150
151
|
created_at TEXT NOT NULL,
|
|
151
152
|
updated_at TEXT NOT NULL,
|
|
152
153
|
completed_at TEXT,
|
|
@@ -196,6 +197,13 @@ export function ensureVnextWorkStorage(db) {
|
|
|
196
197
|
);
|
|
197
198
|
CREATE INDEX IF NOT EXISTS idx_check_receipts_run ON check_receipts(project_id, run_id, started_at);
|
|
198
199
|
`);
|
|
200
|
+
// A committed Work start is authoritative even if publishing its prompt
|
|
201
|
+
// files is interrupted after commit. Keep the exact bytes for repair.
|
|
202
|
+
db.writeTransaction(() => {
|
|
203
|
+
const columns = db.all("PRAGMA table_info(work_sessions)");
|
|
204
|
+
if (!columns.some((column) => column.name === "start_receipt_json"))
|
|
205
|
+
db.execSchema("ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
|
|
206
|
+
});
|
|
199
207
|
}
|
|
200
208
|
/** Durable intent for a provider-root external Work. It is deliberately
|
|
201
209
|
* separate from `work_sessions`: only a trusted hook may bind that Session. */
|
|
@@ -265,16 +273,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
265
273
|
if (mode === "read_existing")
|
|
266
274
|
db.exec("PRAGMA query_only = ON");
|
|
267
275
|
db.exec("PRAGMA foreign_keys = ON");
|
|
268
|
-
return
|
|
269
|
-
path: dbPath,
|
|
270
|
-
writable: mode !== "read_existing",
|
|
271
|
-
close: () => db.close?.(),
|
|
272
|
-
exec: (sql) => db.exec(sql),
|
|
273
|
-
execSchema: (sql) => executeStoreSchema(db, sql),
|
|
274
|
-
run: (sql, params = []) => db.prepare(sql).run(...params),
|
|
275
|
-
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
276
|
-
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
277
|
-
};
|
|
276
|
+
return databaseFacade(dbPath, mode !== "read_existing", db);
|
|
278
277
|
}
|
|
279
278
|
/**
|
|
280
279
|
* Opens the small host-wide resource registry. It is intentionally separate
|
|
@@ -351,16 +350,10 @@ export function getResourceDatabase(resourceHome) {
|
|
|
351
350
|
db.close();
|
|
352
351
|
throw error;
|
|
353
352
|
}
|
|
354
|
-
const database = {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
exec: (sql) => db.exec(sql),
|
|
359
|
-
execSchema: (sql) => executeStoreSchema(db, sql),
|
|
360
|
-
run: (sql, params = []) => db.prepare(sql).run(...params),
|
|
361
|
-
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
362
|
-
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
363
|
-
};
|
|
353
|
+
const database = databaseFacade(dbPath, true, db, () => {
|
|
354
|
+
resourceDatabases.delete(dbPath);
|
|
355
|
+
db.close?.();
|
|
356
|
+
});
|
|
364
357
|
resourceDatabases.set(dbPath, database);
|
|
365
358
|
return database;
|
|
366
359
|
}
|
|
@@ -370,11 +363,108 @@ function emptyReadOnlyDatabase(dbPath) {
|
|
|
370
363
|
writable: false,
|
|
371
364
|
exec: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
372
365
|
execSchema: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
366
|
+
beginWriteTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
|
|
367
|
+
writeTransaction: () => { throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath }); },
|
|
373
368
|
run: () => { throw new AppError("read_only_storage", "Read-only access cannot write absent dd-flow storage", 1, { path: dbPath }); },
|
|
374
369
|
get: () => undefined,
|
|
375
370
|
all: () => []
|
|
376
371
|
};
|
|
377
372
|
}
|
|
373
|
+
function databaseFacade(dbPath, writable, db, close = () => db.close?.()) {
|
|
374
|
+
let reserved = false;
|
|
375
|
+
let poisoned = false;
|
|
376
|
+
let savepoint = 0;
|
|
377
|
+
const assertUsable = () => { if (poisoned)
|
|
378
|
+
throw new AppError("write_transaction_unresolved", "Transaction cleanup failed; reopen the connection before continuing", 1); };
|
|
379
|
+
const beginWriteTransaction = () => {
|
|
380
|
+
assertUsable();
|
|
381
|
+
if (!writable)
|
|
382
|
+
throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
|
|
383
|
+
db.exec("BEGIN IMMEDIATE");
|
|
384
|
+
reserved = true;
|
|
385
|
+
};
|
|
386
|
+
const rollback = (sql, original) => {
|
|
387
|
+
try {
|
|
388
|
+
db.exec(sql);
|
|
389
|
+
}
|
|
390
|
+
catch (cleanup) {
|
|
391
|
+
poisoned = true;
|
|
392
|
+
if (original instanceof AppError)
|
|
393
|
+
throw new AppError(original.code, original.message, original.exitCode, { ...original.details, transaction_cleanup_error: errorDetails(cleanup) });
|
|
394
|
+
throw new AppError("write_transaction_failed", "Write transaction failed and SQLite cleanup also failed", 1, { cause: errorDetails(original), transaction_cleanup_error: errorDetails(cleanup) });
|
|
395
|
+
}
|
|
396
|
+
throw original;
|
|
397
|
+
};
|
|
398
|
+
const writeTransaction = (operation) => {
|
|
399
|
+
assertUsable();
|
|
400
|
+
if (!writable)
|
|
401
|
+
throw new AppError("read_only_storage", "Read-only access cannot start a write transaction", 1, { path: dbPath });
|
|
402
|
+
if (db.isTransaction && !reserved)
|
|
403
|
+
throw new AppError("write_transaction_unowned", "Cannot write inside an unowned transaction or read snapshot", 1);
|
|
404
|
+
const nested = db.isTransaction;
|
|
405
|
+
const marker = `dd_flow_write_${++savepoint}`;
|
|
406
|
+
try {
|
|
407
|
+
if (nested)
|
|
408
|
+
db.exec(`SAVEPOINT ${marker}`);
|
|
409
|
+
else
|
|
410
|
+
beginWriteTransaction();
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
throw new AppError("storage_write_failed", "Cannot reserve SQLite writer", 1, { cause: errorDetails(error), transaction_phase: "begin", transaction_rolled_back: true });
|
|
414
|
+
}
|
|
415
|
+
let phase = "body";
|
|
416
|
+
try {
|
|
417
|
+
const value = operation();
|
|
418
|
+
if (value && typeof value.then === "function") {
|
|
419
|
+
void Promise.resolve(value).catch(() => { });
|
|
420
|
+
throw new AppError("write_transaction_async", "Write transaction callback must be synchronous", 1);
|
|
421
|
+
}
|
|
422
|
+
phase = "commit";
|
|
423
|
+
if (nested)
|
|
424
|
+
db.exec(`RELEASE ${marker}`);
|
|
425
|
+
else
|
|
426
|
+
db.exec("COMMIT");
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
try {
|
|
431
|
+
return rollback(nested ? `ROLLBACK TO ${marker}; RELEASE ${marker}` : "ROLLBACK", error);
|
|
432
|
+
}
|
|
433
|
+
catch (failure) {
|
|
434
|
+
if (failure === error && !(error instanceof AppError) && typeof error === "object" && error !== null && "code" in error && String(error.code).startsWith("ERR_SQLITE"))
|
|
435
|
+
throw new AppError("storage_write_failed", "SQLite write failed", 1, { cause: errorDetails(error), transaction_phase: phase, transaction_rolled_back: true });
|
|
436
|
+
throw failure;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
if (!db.isTransaction)
|
|
441
|
+
reserved = false;
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
return {
|
|
445
|
+
path: dbPath,
|
|
446
|
+
writable,
|
|
447
|
+
close,
|
|
448
|
+
exec: (sql) => { assertUsable(); try {
|
|
449
|
+
db.exec(sql);
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
if (!db.isTransaction)
|
|
453
|
+
reserved = false;
|
|
454
|
+
} },
|
|
455
|
+
execSchema: (sql) => { assertUsable(); if (db.isTransaction && !reserved)
|
|
456
|
+
throw new AppError("write_transaction_unowned", "Cannot change schema inside an unowned transaction", 1); executeStoreSchema(db, sql); },
|
|
457
|
+
beginWriteTransaction,
|
|
458
|
+
writeTransaction,
|
|
459
|
+
run: (sql, params = []) => { assertUsable(); return db.prepare(sql).run(...params); },
|
|
460
|
+
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
461
|
+
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function errorDetails(error) {
|
|
465
|
+
const value = error;
|
|
466
|
+
return { message: String(value?.message ?? error), ...(typeof value?.code === "string" ? { code: value.code } : {}), ...(typeof value?.errcode === "number" ? { sqlite_extended_code: value.errcode } : {}) };
|
|
467
|
+
}
|
|
378
468
|
function configureDatabase(db, mode) {
|
|
379
469
|
if (mode !== "read_existing") {
|
|
380
470
|
// Concurrent first openers can collide while changing journal mode, even
|
|
@@ -1026,6 +1116,7 @@ function migrate(db) {
|
|
|
1026
1116
|
ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
|
|
1027
1117
|
ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
|
|
1028
1118
|
ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
|
|
1119
|
+
ensureColumn(db, "work_sessions", "start_receipt_json", "ALTER TABLE work_sessions ADD COLUMN start_receipt_json TEXT");
|
|
1029
1120
|
ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
|
|
1030
1121
|
ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
|
|
1031
1122
|
ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
|
|
@@ -1055,6 +1146,7 @@ function migrate(db) {
|
|
|
1055
1146
|
ensureColumn(db, "hook_events", "parent_session_id", "ALTER TABLE hook_events ADD COLUMN parent_session_id TEXT");
|
|
1056
1147
|
ensureColumn(db, "hook_events", "daemon_id", "ALTER TABLE hook_events ADD COLUMN daemon_id TEXT");
|
|
1057
1148
|
ensureColumn(db, "hook_events", "cwd", "ALTER TABLE hook_events ADD COLUMN cwd TEXT");
|
|
1149
|
+
ensureColumn(db, "hook_events", "outcome_json", "ALTER TABLE hook_events ADD COLUMN outcome_json TEXT");
|
|
1058
1150
|
ensureColumn(db, "harness_usage_snapshots", "tool_calls", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_calls INTEGER");
|
|
1059
1151
|
ensureColumn(db, "harness_usage_snapshots", "tool_failures", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_failures INTEGER");
|
|
1060
1152
|
ensureColumn(db, "harness_usage_snapshots", "tool_by_name_json", "ALTER TABLE harness_usage_snapshots ADD COLUMN tool_by_name_json TEXT");
|