@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.11
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 +77 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +3 -3
- package/dist/cli/run-cli.js +127 -8
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +1 -1
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cleanup.js +18 -8
- package/dist/services/code-checks.js +194 -44
- package/dist/services/engines.js +4 -4
- package/dist/services/eval-snapshots.js +10 -5
- package/dist/services/harness-config.js +66 -0
- package/dist/services/hooks.js +62 -28
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +8 -2
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/prompts.js +4 -2
- package/dist/services/run-engine-bindings.js +19 -61
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +71 -9
- package/dist/services/schema-validation.js +11 -11
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-lifecycle.js +15 -8
- package/dist/services/stage-pause.js +35 -20
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +82 -41
- package/dist/services/vnext-code.js +98 -34
- package/dist/services/vnext-fanout.js +5 -12
- package/dist/services/vnext-merge.js +144 -65
- package/dist/services/vnext-plan-review.js +50 -35
- package/dist/services/vnext-plan.js +69 -21
- package/dist/services/vnext-protocolize.js +6 -6
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +150 -40
- package/dist/storage/database.js +128 -2
- package/package.json +1 -1
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -4,12 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
5
|
import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
|
-
import { resolveProjectRoot, resolveRunReferences
|
|
7
|
+
import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
|
|
8
8
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
|
-
import { readCodeCheckProfile, runCodeChecks } from "./code-checks.js";
|
|
9
|
+
import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
|
|
10
10
|
import { nextWorkId, nextWorkIds } from "./ids.js";
|
|
11
11
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
12
|
import { flowCommand } from "./stage-pause.js";
|
|
13
|
+
import { assertPortableArtifactRef } from "./portable-refs.js";
|
|
14
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
13
15
|
const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
|
|
14
16
|
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
15
17
|
export function createChildWork(context, input) {
|
|
@@ -111,7 +113,7 @@ export function listWorks(context, input) {
|
|
|
111
113
|
return { ...work, payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), ready: isReadyNow, ...(isReadyNow ? { start_command: workStartCommand(context, work) } : {}), ...(input.includeResults ? {} : { result: undefined }) };
|
|
112
114
|
}) };
|
|
113
115
|
}
|
|
114
|
-
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? ORDER BY created_at", [work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions } }; }
|
|
116
|
+
export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
|
|
115
117
|
export function mutateWorkDeps(context, input) {
|
|
116
118
|
const work = requireWork(context, input.workId);
|
|
117
119
|
if (input.action === "list")
|
|
@@ -262,6 +264,17 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
262
264
|
fs.mkdirSync(directory, { recursive: true });
|
|
263
265
|
const promptPath = path.join(directory, "prompt.md");
|
|
264
266
|
const resultPath = path.join(directory, "result.json");
|
|
267
|
+
const contextPath = path.join(directory, "context.json");
|
|
268
|
+
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
269
|
+
const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
|
|
270
|
+
const payload = parsePayload(work);
|
|
271
|
+
const readOnly = payload?.read_only === true;
|
|
272
|
+
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, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
|
|
273
|
+
const token = crypto.randomUUID();
|
|
274
|
+
const promptCandidate = `${promptPath}.${token}.tmp`;
|
|
275
|
+
const contextCandidate = `${contextPath}.${token}.tmp`;
|
|
276
|
+
fs.writeFileSync(promptCandidate, prompt);
|
|
277
|
+
fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
|
|
265
278
|
context.db.exec("BEGIN IMMEDIATE");
|
|
266
279
|
try {
|
|
267
280
|
bindSession(context, work, run, identity, now);
|
|
@@ -271,23 +284,23 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
|
271
284
|
if (claimed.changes !== 1)
|
|
272
285
|
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
|
|
273
286
|
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)`, [linkId, work.work_id, identity.sessionId, hookEventId, promptPath, resultPath, now, now]);
|
|
287
|
+
fs.renameSync(promptCandidate, promptPath);
|
|
288
|
+
fs.renameSync(contextCandidate, contextPath);
|
|
274
289
|
context.db.exec("COMMIT");
|
|
275
290
|
}
|
|
276
291
|
catch (error) {
|
|
277
292
|
context.db.exec("ROLLBACK");
|
|
293
|
+
fs.rmSync(promptCandidate, { force: true });
|
|
294
|
+
fs.rmSync(contextCandidate, { force: true });
|
|
278
295
|
throw error;
|
|
279
296
|
}
|
|
280
|
-
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
281
|
-
const prompt = renderWorkerPrompt(context, work, run, dependencyResults, resultPath);
|
|
282
|
-
writeJsonAtomic(path.join(directory, "context.json"), { 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 });
|
|
283
|
-
fs.writeFileSync(promptPath, prompt);
|
|
284
297
|
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
285
|
-
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session_id: identity.
|
|
298
|
+
return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
|
|
286
299
|
}
|
|
287
300
|
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
288
301
|
/** Structured fan-in closes a parent after its Session was handed to a child. */
|
|
289
302
|
export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
|
|
290
|
-
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running') LIMIT 1", [work.work_id]))
|
|
303
|
+
throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
|
|
291
304
|
throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
|
|
292
305
|
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
293
306
|
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
@@ -302,7 +315,7 @@ async function settle(context, id, status, result, progress) {
|
|
|
302
315
|
id = work.work_id;
|
|
303
316
|
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
304
317
|
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
305
|
-
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running') LIMIT 1", [id]))
|
|
318
|
+
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [id]))
|
|
306
319
|
throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
|
|
307
320
|
const run = requireRun(context, work.project_id, work.run_id);
|
|
308
321
|
const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
|
|
@@ -311,7 +324,14 @@ async function settle(context, id, status, result, progress) {
|
|
|
311
324
|
let receipts = [];
|
|
312
325
|
let coordinationDrift = [];
|
|
313
326
|
if (status === "completed") {
|
|
314
|
-
|
|
327
|
+
if (parsePayload(work)?.read_only === true) {
|
|
328
|
+
const contextFile = link ? path.join(path.dirname(link.prompt_path), "context.json") : "";
|
|
329
|
+
const baseline = contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null;
|
|
330
|
+
const current = workspaceFingerprint(run.workspace_root);
|
|
331
|
+
if (typeof baseline !== "string" || baseline !== current)
|
|
332
|
+
throw new AppError("read_only_work_mutated_workspace", "Read-only Work changed the accepted workspace", 2, { work_id: work.work_id, baseline, current });
|
|
333
|
+
}
|
|
334
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
|
|
315
335
|
const packet = codePacket(work);
|
|
316
336
|
if (packet) {
|
|
317
337
|
// CODE packets are projected from a PLAN that already validated this
|
|
@@ -362,21 +382,33 @@ function bindSession(context, work, run, identity, now) {
|
|
|
362
382
|
const inferredParentSession = work.parent_work_id
|
|
363
383
|
? context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.parent_work_id])?.session_id ?? null
|
|
364
384
|
: priorWorkSession && priorWorkSession !== identity.sessionId ? priorWorkSession : null;
|
|
365
|
-
|
|
385
|
+
// Work ancestry and provider-tree containment are different relations. An
|
|
386
|
+
// isolated worker is logically a child Work but physically a provider root.
|
|
387
|
+
const parentSession = inferredParentSession ?? identity.parentSessionId;
|
|
388
|
+
const providerParentSession = identity.parentSessionId;
|
|
366
389
|
if (work.parent_work_id && !parentSession)
|
|
367
390
|
throw new AppError("parent_session_required", "Child Work requires a confirmed parent Work/Session link", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
|
|
368
391
|
if (work.launch_policy === "fresh_agent_required" && (identity.sessionId === parentSession || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? LIMIT 1", [work.project_id, work.run_id, identity.sessionId])))
|
|
369
392
|
throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, session_id: identity.sessionId });
|
|
370
|
-
const existing = context.db.get("SELECT session_id, parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
393
|
+
const existing = context.db.get("SELECT session_id, parent_session_id, provider_parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
371
394
|
if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
|
|
372
395
|
throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
|
|
373
|
-
|
|
396
|
+
if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
|
|
397
|
+
throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { session_id: identity.sessionId });
|
|
398
|
+
context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = excluded.cwd, updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, run.workspace_root, now, now]);
|
|
374
399
|
reactivateBoundSession(context, work.project_id, identity.sessionId, now);
|
|
375
400
|
}
|
|
401
|
+
/** Register the trusted physical Session before a paused Work moves to it. */
|
|
402
|
+
export function bindSessionForResume(context, workId, identity, now) {
|
|
403
|
+
const work = requireWork(context, workId);
|
|
404
|
+
if (work.status !== "paused")
|
|
405
|
+
throw new AppError("invalid_work_state", "Only a paused Work can bind a resume Session", 1, { work_id: work.work_id, status: work.status });
|
|
406
|
+
bindSession(context, work, requireRun(context, work.project_id, work.run_id), identity, now);
|
|
407
|
+
}
|
|
376
408
|
function reactivateBoundSession(context, projectId, sessionId, now) {
|
|
377
409
|
context.db.run("UPDATE sessions SET status = 'active', stop_reason = NULL, stopped_at = NULL, updated_at = ? WHERE project_id = ? AND session_id = ?", [now, projectId, sessionId]);
|
|
378
410
|
}
|
|
379
|
-
function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, resultPath) {
|
|
411
|
+
function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
|
|
380
412
|
if (!work.result_schema)
|
|
381
413
|
return;
|
|
382
414
|
if (!resultPath)
|
|
@@ -388,20 +420,35 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, res
|
|
|
388
420
|
catch {
|
|
389
421
|
throw new AppError("validation", "Work result must be JSON for its declared result schema", 2, { work_id: work.work_id, result_schema: work.result_schema });
|
|
390
422
|
}
|
|
391
|
-
|
|
392
|
-
|
|
423
|
+
const candidate = `${resultPath}.candidate-${process.pid}`;
|
|
424
|
+
fs.writeFileSync(candidate, result);
|
|
425
|
+
try {
|
|
426
|
+
validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId, runRoot: runHome });
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
fs.rmSync(candidate, { force: true });
|
|
430
|
+
}
|
|
393
431
|
if (work.result_schema === "dd-flow/code-work-result@2")
|
|
394
|
-
validateCodeWorkResult(work, parsed, workspaceRoot);
|
|
432
|
+
validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
|
|
395
433
|
if (work.result_schema === "dd-flow/code-review-result@1")
|
|
396
|
-
|
|
434
|
+
validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
435
|
+
if (work.result_schema === "dd-flow/plan-review-result@1")
|
|
436
|
+
validatePlanReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
397
437
|
}
|
|
398
|
-
function validateCodeWorkResult(work, value, projectRoot) {
|
|
438
|
+
function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
399
439
|
const result = value;
|
|
400
440
|
if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
|
|
401
441
|
throw new AppError("work_contract_incomplete", "CODE Work cannot complete with unresolved deviations or blockers; fail the Work and report the contract mismatch", 2, { work_id: work.work_id, deviations: result.deviations ?? [], blockers: result.blockers ?? [] });
|
|
402
442
|
const packet = codePacket(work);
|
|
403
443
|
if (!packet)
|
|
404
444
|
return;
|
|
445
|
+
const acceptedCriteria = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
|
|
446
|
+
for (const item of result.evidence ?? []) {
|
|
447
|
+
if (!item.criterion_id || !acceptedCriteria.has(item.criterion_id))
|
|
448
|
+
throw new AppError("evidence_criterion_unknown", "CODE Work evidence must reference an acceptance criterion assigned to this Work", 2, { work_id: work.work_id, criterion_id: item.criterion_id ?? null });
|
|
449
|
+
for (const ref of item.refs ?? [])
|
|
450
|
+
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
451
|
+
}
|
|
405
452
|
const documentUpdates = (packet.document_updates ?? []);
|
|
406
453
|
const changed = new Set(result.changed_paths ?? []);
|
|
407
454
|
const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
|
|
@@ -411,33 +458,43 @@ function validateCodeWorkResult(work, value, projectRoot) {
|
|
|
411
458
|
return true; const current = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); return entry.action === "create" ? entry.baseline_sha256 !== null : current === entry.baseline_sha256; }).map((entry) => entry.path);
|
|
412
459
|
if (unchangedDocuments.length)
|
|
413
460
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
414
|
-
const assigned = packet.repair?.
|
|
461
|
+
const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
|
|
415
462
|
if (assigned.length) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
463
|
+
if ((result.changed_paths?.length ?? 0) === 0)
|
|
464
|
+
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
465
|
+
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
466
|
+
for (const resolution of result.resolutions ?? [])
|
|
467
|
+
for (const ref of resolution.evidence_refs ?? [])
|
|
468
|
+
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
421
469
|
}
|
|
422
470
|
}
|
|
423
|
-
function
|
|
471
|
+
export function validateReviewResolutions(workId, assigned, resolutions) {
|
|
472
|
+
const resolved = new Set(resolutions.map((resolution) => resolution.finding_ref).filter((ref) => Boolean(ref)));
|
|
473
|
+
const missing = assigned.filter((finding) => !resolved.has(finding));
|
|
474
|
+
const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
|
|
475
|
+
const duplicate = resolutions.length !== resolved.size;
|
|
476
|
+
if (missing.length || unexpected.length || duplicate)
|
|
477
|
+
throw new AppError("review_repair_incomplete", "Review repair must include exactly one resolution for each assigned finding reference", 2, { work_id: workId, missing, unexpected, duplicate });
|
|
478
|
+
}
|
|
479
|
+
function renderWorkerPrompt(context, work, run, dependencies) {
|
|
424
480
|
const command = flowCommand(context);
|
|
425
481
|
const packet = codePacket(work);
|
|
426
|
-
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
482
|
+
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
427
483
|
if (packet)
|
|
428
484
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
429
|
-
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE:
|
|
485
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not.", "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
430
486
|
}
|
|
431
|
-
function resultSchemaGuidance(work) {
|
|
487
|
+
function resultSchemaGuidance(work, runId) {
|
|
432
488
|
const schema = work.result_schema;
|
|
489
|
+
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
433
490
|
if (schema === "dd-flow/code-work-result@2")
|
|
434
|
-
return ["Use this complete minimal shape. For a review repair,
|
|
491
|
+
return [refs, "Use this complete minimal shape. For a review repair, include exactly one resolution per assigned finding; explain what changed and cite evidence that demonstrates the required outcome:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence", `run://${runId}/05-code/checks/receipt.json`] }], deviations: [], blockers: [], resolutions: [{ finding_ref: "WRK-000/FIND-001", summary: "How the required outcome was achieved.", evidence_refs: ["project-relative/evidence"] }] }, null, 2), "```"];
|
|
435
492
|
if (schema === "dd-flow/code-review-result@1") {
|
|
436
|
-
return ["Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file"] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
493
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file", `run://${runId}/05-code/checks/receipt.json`] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
437
494
|
}
|
|
438
495
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
439
496
|
return [];
|
|
440
|
-
return ["Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file"], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
497
|
+
return [refs, "Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file", `run://${runId}/03-plan/plan.json`], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
441
498
|
}
|
|
442
499
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
443
500
|
const group = codeReviewGroup(work);
|
|
@@ -458,6 +515,57 @@ export function validateCodeReviewResultIdentity(work, value) {
|
|
|
458
515
|
throw new AppError("review_evidence_invalid", "CODE reviewer finding ids must be local FIND-NNN ids and aspect refs must stay inside the assigned review group", 2, { work_id: work.work_id, group: group.key, required_format: "FIND-NNN", invalid, wrong_aspect: wrongAspect });
|
|
459
516
|
}
|
|
460
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* CODE review evidence is part of the Work contract, not a late stage-level
|
|
520
|
+
* concern. Reject it before `works.result` becomes authoritative so a later
|
|
521
|
+
* fan-in cannot discover a malformed accepted reviewer result.
|
|
522
|
+
*/
|
|
523
|
+
function validateCodeReviewResult(work, value, input) {
|
|
524
|
+
validateCodeReviewResultIdentity(work, value);
|
|
525
|
+
const result = value;
|
|
526
|
+
const findings = result.findings ?? [];
|
|
527
|
+
// A reviewer is a child Work and cannot pause its coordinator-owned Stage.
|
|
528
|
+
// Its structured blocked result is evidence for the coordinator, which then
|
|
529
|
+
// either resolves the gap or pauses the Stage itself.
|
|
530
|
+
if ((result.verdict === "pass" && findings.length > 0) || (result.verdict === "findings" && findings.length === 0)) {
|
|
531
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer verdict must agree with whether material findings are present", 2, { work_id: work.work_id, verdict: result.verdict, findings: findings.length });
|
|
532
|
+
}
|
|
533
|
+
const references = [
|
|
534
|
+
...(result.aspects ?? []).flatMap((aspect) => Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
535
|
+
...(result.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
536
|
+
];
|
|
537
|
+
for (const ref of references) {
|
|
538
|
+
if (typeof ref !== "string")
|
|
539
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
540
|
+
assertPortableArtifactRef(ref, input);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
export function validatePlanReviewResult(work, value, input) {
|
|
544
|
+
const group = codeReviewGroup(work);
|
|
545
|
+
if (!group)
|
|
546
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer Work has no assigned review group", 2, { work_id: work.work_id });
|
|
547
|
+
const result = value;
|
|
548
|
+
const aspects = result.aspects ?? [];
|
|
549
|
+
const ids = aspects.map((item) => item.aspect_id ?? "");
|
|
550
|
+
const expected = new Set(group.aspect_ids);
|
|
551
|
+
const missing = group.aspect_ids.filter((id) => !ids.includes(id));
|
|
552
|
+
const unexpected = ids.filter((id) => !expected.has(id));
|
|
553
|
+
if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
|
|
554
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
|
|
555
|
+
const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
|
|
556
|
+
const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
|
|
557
|
+
if (new Set(findingIds).size !== findingIds.length || invalid.length)
|
|
558
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer finding ids must be unique local FIND-NNN ids", 2, { work_id: work.work_id, invalid });
|
|
559
|
+
const refs = aspects.flatMap((aspect) => [
|
|
560
|
+
...(Array.isArray(aspect.evidence_refs) ? aspect.evidence_refs : []),
|
|
561
|
+
...(aspect.findings ?? []).flatMap((finding) => Array.isArray(finding.evidence_refs) ? finding.evidence_refs : [])
|
|
562
|
+
]);
|
|
563
|
+
for (const ref of refs) {
|
|
564
|
+
if (typeof ref !== "string")
|
|
565
|
+
throw new AppError("review_evidence_invalid", "PLAN reviewer evidence references must be strings", 2, { work_id: work.work_id, ref });
|
|
566
|
+
assertPortableArtifactRef(ref, input);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
461
569
|
function codeReviewGroup(work) {
|
|
462
570
|
const payload = parsePayload(work);
|
|
463
571
|
const group = payload?.group;
|
|
@@ -472,10 +580,10 @@ function requireWork(context, id) { ensureWorkRegistry(context); const exact = c
|
|
|
472
580
|
return exact; if (!/^WRK-\d{3,}$/.test(id))
|
|
473
581
|
throw new AppError("not_found", "Work is not registered", 1, { work_id: id }); const matches = context.db.all(`SELECT ${workColumns} FROM works WHERE work_id LIKE ? ORDER BY work_id`, [`${id}-%`]); if (matches.length !== 1)
|
|
474
582
|
throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous" : "Work is not registered", 1, { work_id: id, matches: matches.map((work) => work.work_id) }); return matches[0]; }
|
|
475
|
-
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.
|
|
476
|
-
throw new AppError("runtime_missing", "RUN
|
|
477
|
-
function requireRunHome(run) { if (!run.
|
|
478
|
-
throw new AppError("runtime_missing", "RUN
|
|
583
|
+
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]); if (!run?.run_root)
|
|
584
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
|
|
585
|
+
function requireRunHome(run) { if (!run.run_root)
|
|
586
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id }); return run.run_root; }
|
|
479
587
|
function parseDependencies(work) { try {
|
|
480
588
|
const value = JSON.parse(work.depends_on_json);
|
|
481
589
|
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
|
|
@@ -505,20 +613,22 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
|
|
|
505
613
|
throw new AppError("validation", "work item must be an object", 2); const item = value; if (typeof item.key !== "string" || !item.key || typeof item.task !== "string" || !item.task.trim())
|
|
506
614
|
throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@5"; if (requireExecutionContext && !code)
|
|
507
615
|
throw new AppError("validation", "CODE batch requires code-work-packet@5 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions"] : [])
|
|
508
|
-
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas'].includes(key) && item[key].length === 0))
|
|
616
|
+
if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas', 'checks'].includes(key) && item[key].length === 0) || (key === "checks" && item[key].length === 0 && !allowsEmptyRepairChecks(item)))
|
|
509
617
|
throw new AppError("validation", `CODE work item requires ${['provides_checks', 'planned_write_areas'].includes(key) ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
|
|
510
618
|
throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
|
|
511
619
|
throw new AppError("validation", "parent must be a string", 2); if (item.launch_policy !== undefined && item.launch_policy !== "reuse_allowed" && item.launch_policy !== "fresh_agent_required")
|
|
512
620
|
throw new AppError("validation", "launch_policy must be reuse_allowed or fresh_agent_required", 2); if (item.result_schema !== undefined && (typeof item.result_schema !== "string" || !item.result_schema))
|
|
513
621
|
throw new AppError("validation", "result_schema must be a non-empty schema id", 2); const payload = code ? item : (item.payload && typeof item.payload === "object" && !Array.isArray(item.payload) ? item.payload : undefined); return { key: item.key, task: item.task, ...(Array.isArray(item.depends_on) ? { depends_on: item.depends_on } : {}), ...(typeof item.parent === "string" ? { parent: item.parent } : {}), ...(typeof item.launch_policy === "string" ? { launch_policy: item.launch_policy } : {}), ...(typeof item.result_schema === "string" ? { result_schema: item.result_schema } : {}), ...(payload ? { payload } : {}) }; }
|
|
622
|
+
function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
623
|
+
return false; const value = repair; return typeof value.check_receipt_id === "string" || (Array.isArray(value.review_findings) && value.review_findings.some((finding) => typeof finding === "object" && finding !== null && Array.isArray(finding.check_refs) && finding.check_refs.some((ref) => typeof ref === "string"))) || (Array.isArray(value.semantic_unresolved) && value.semantic_unresolved.some((item) => typeof item === "string")); }
|
|
514
624
|
function readJson(file) { try {
|
|
515
625
|
return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
|
|
516
626
|
}
|
|
517
627
|
catch (error) {
|
|
518
628
|
throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
|
|
519
629
|
} }
|
|
520
|
-
export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT
|
|
521
|
-
const obsolete = path.join(run.
|
|
630
|
+
export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_root) {
|
|
631
|
+
const obsolete = path.join(run.run_root, "work.json");
|
|
522
632
|
if (fs.existsSync(obsolete))
|
|
523
633
|
fs.rmSync(obsolete);
|
|
524
634
|
} }
|
package/dist/storage/database.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { createRequire } from "node:module";
|
|
4
5
|
import { ensureDir } from "./paths.js";
|
|
5
6
|
import { AppError } from "../shared/errors.js";
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { DatabaseSync } = require("node:sqlite");
|
|
9
|
+
const resourceDatabases = new Map();
|
|
8
10
|
/** Creates the single Work/Session authority used by a fresh vNext beta runtime. */
|
|
9
11
|
export function ensureVnextWorkStorage(db) {
|
|
10
12
|
const legacy = db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('vnext_works', 'vnext_agent_turns', 'flow_agent_turns') LIMIT 1");
|
|
@@ -94,7 +96,7 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
94
96
|
// Internal additive schema changes must be available to every write command.
|
|
95
97
|
// Higher-level Memory Bank migrations remain explicit in services/migrations.
|
|
96
98
|
if (mode !== "read_existing")
|
|
97
|
-
migrate(db);
|
|
99
|
+
migrate(db, dbPath);
|
|
98
100
|
if (mode === "read_existing")
|
|
99
101
|
db.exec("PRAGMA query_only = ON");
|
|
100
102
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -108,6 +110,72 @@ export function getDatabase(ddFlowHome, mode = "initialize") {
|
|
|
108
110
|
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
109
111
|
};
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Opens the small host-wide resource registry. It is intentionally separate
|
|
115
|
+
* from a RUN database: a process or a port can outlive the CLI client that
|
|
116
|
+
* created it, while a RUN database belongs to one flow home.
|
|
117
|
+
*/
|
|
118
|
+
export function getResourceDatabase(resourceHome) {
|
|
119
|
+
ensureDir(resourceHome);
|
|
120
|
+
const dbPath = path.join(resourceHome, "runtime.sqlite");
|
|
121
|
+
const cached = resourceDatabases.get(dbPath);
|
|
122
|
+
if (cached)
|
|
123
|
+
return cached;
|
|
124
|
+
const db = new DatabaseSync(dbPath);
|
|
125
|
+
configureDatabase(db, "initialize");
|
|
126
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
127
|
+
db.exec(`
|
|
128
|
+
CREATE TABLE IF NOT EXISTS managed_processes (
|
|
129
|
+
id TEXT PRIMARY KEY,
|
|
130
|
+
kind TEXT NOT NULL,
|
|
131
|
+
pid INTEGER,
|
|
132
|
+
pid_started_at TEXT,
|
|
133
|
+
owner_id TEXT NOT NULL,
|
|
134
|
+
lease_token TEXT NOT NULL,
|
|
135
|
+
lease_expires_at TEXT NOT NULL,
|
|
136
|
+
project_id TEXT,
|
|
137
|
+
run_id TEXT,
|
|
138
|
+
work_id TEXT,
|
|
139
|
+
check_id TEXT,
|
|
140
|
+
operation_id TEXT,
|
|
141
|
+
stdout_path TEXT,
|
|
142
|
+
stderr_path TEXT,
|
|
143
|
+
state TEXT NOT NULL,
|
|
144
|
+
started_at TEXT NOT NULL,
|
|
145
|
+
updated_at TEXT NOT NULL,
|
|
146
|
+
finished_at TEXT,
|
|
147
|
+
termination_reason TEXT,
|
|
148
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
149
|
+
);
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_managed_processes_lease
|
|
151
|
+
ON managed_processes(state, lease_expires_at, updated_at);
|
|
152
|
+
CREATE TABLE IF NOT EXISTS managed_resources (
|
|
153
|
+
resource_kind TEXT NOT NULL,
|
|
154
|
+
resource_key TEXT NOT NULL,
|
|
155
|
+
owner_id TEXT NOT NULL,
|
|
156
|
+
lease_token TEXT NOT NULL,
|
|
157
|
+
lease_expires_at TEXT NOT NULL,
|
|
158
|
+
process_id TEXT,
|
|
159
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
160
|
+
created_at TEXT NOT NULL,
|
|
161
|
+
updated_at TEXT NOT NULL,
|
|
162
|
+
PRIMARY KEY(resource_kind, resource_key)
|
|
163
|
+
);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_managed_resources_lease
|
|
165
|
+
ON managed_resources(lease_expires_at, updated_at);
|
|
166
|
+
`);
|
|
167
|
+
const database = {
|
|
168
|
+
path: dbPath,
|
|
169
|
+
writable: true,
|
|
170
|
+
close: () => { resourceDatabases.delete(dbPath); db.close?.(); },
|
|
171
|
+
exec: (sql) => db.exec(sql),
|
|
172
|
+
run: (sql, params = []) => db.prepare(sql).run(...params),
|
|
173
|
+
get: (sql, params = []) => db.prepare(sql).get(...params),
|
|
174
|
+
all: (sql, params = []) => db.prepare(sql).all(...params)
|
|
175
|
+
};
|
|
176
|
+
resourceDatabases.set(dbPath, database);
|
|
177
|
+
return database;
|
|
178
|
+
}
|
|
111
179
|
function emptyReadOnlyDatabase(dbPath) {
|
|
112
180
|
return {
|
|
113
181
|
path: dbPath,
|
|
@@ -125,7 +193,7 @@ function configureDatabase(db, mode) {
|
|
|
125
193
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
126
194
|
}
|
|
127
195
|
}
|
|
128
|
-
function migrate(db) {
|
|
196
|
+
function migrate(db, dbPath) {
|
|
129
197
|
db.exec(`
|
|
130
198
|
CREATE TABLE IF NOT EXISTS projects (
|
|
131
199
|
id TEXT PRIMARY KEY,
|
|
@@ -351,6 +419,7 @@ function migrate(db) {
|
|
|
351
419
|
project_id TEXT NOT NULL,
|
|
352
420
|
harness TEXT NOT NULL DEFAULT 'codex-desktop',
|
|
353
421
|
provider_session_id TEXT,
|
|
422
|
+
provider_parent_session_id TEXT,
|
|
354
423
|
agent_id TEXT,
|
|
355
424
|
parent_id TEXT,
|
|
356
425
|
provider TEXT,
|
|
@@ -495,6 +564,7 @@ function migrate(db) {
|
|
|
495
564
|
target_branch TEXT NOT NULL,
|
|
496
565
|
enqueue_target_head TEXT,
|
|
497
566
|
execution_target_head TEXT,
|
|
567
|
+
accepted_tree TEXT,
|
|
498
568
|
integration_commit TEXT,
|
|
499
569
|
execution_route TEXT NOT NULL,
|
|
500
570
|
status TEXT NOT NULL,
|
|
@@ -713,6 +783,7 @@ function migrate(db) {
|
|
|
713
783
|
ensureColumn(db, "sessions", "metadata_json", "ALTER TABLE sessions ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
|
|
714
784
|
ensureColumn(db, "sessions", "run_id", "ALTER TABLE sessions ADD COLUMN run_id TEXT");
|
|
715
785
|
ensureColumn(db, "sessions", "provider_session_id", "ALTER TABLE sessions ADD COLUMN provider_session_id TEXT");
|
|
786
|
+
ensureColumn(db, "sessions", "provider_parent_session_id", "ALTER TABLE sessions ADD COLUMN provider_parent_session_id TEXT");
|
|
716
787
|
ensureColumn(db, "sessions", "harness", "ALTER TABLE sessions ADD COLUMN harness TEXT NOT NULL DEFAULT 'codex-desktop'");
|
|
717
788
|
ensureColumn(db, "sessions", "agent_id", "ALTER TABLE sessions ADD COLUMN agent_id TEXT");
|
|
718
789
|
ensureColumn(db, "sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT");
|
|
@@ -727,6 +798,7 @@ function migrate(db) {
|
|
|
727
798
|
ensureColumn(db, "sessions", "plan_item_id", "ALTER TABLE sessions ADD COLUMN plan_item_id TEXT");
|
|
728
799
|
ensureColumn(db, "sessions", "session_kind", "ALTER TABLE sessions ADD COLUMN session_kind TEXT");
|
|
729
800
|
ensureColumn(db, "sessions", "coverage_units_json", "ALTER TABLE sessions ADD COLUMN coverage_units_json TEXT NOT NULL DEFAULT '[]'");
|
|
801
|
+
migrateSessionStorageKeys(db, dbPath);
|
|
730
802
|
ensureColumn(db, "usage", "cache_read_input_tokens", "ALTER TABLE usage ADD COLUMN cache_read_input_tokens INTEGER");
|
|
731
803
|
ensureColumn(db, "usage", "cache_write_input_tokens", "ALTER TABLE usage ADD COLUMN cache_write_input_tokens INTEGER");
|
|
732
804
|
ensureColumn(db, "usage", "uncached_input_tokens", "ALTER TABLE usage ADD COLUMN uncached_input_tokens INTEGER");
|
|
@@ -737,6 +809,8 @@ function migrate(db) {
|
|
|
737
809
|
db.prepare("UPDATE usage SET cache_read_input_tokens = cached_input_tokens WHERE cache_read_input_tokens IS NULL AND cached_input_tokens IS NOT NULL").run();
|
|
738
810
|
ensureColumn(db, "runs", "run_home_path", "ALTER TABLE runs ADD COLUMN run_home_path TEXT");
|
|
739
811
|
ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
|
|
812
|
+
db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
|
|
813
|
+
ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
|
|
740
814
|
ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
|
|
741
815
|
ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
|
|
742
816
|
ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
|
|
@@ -783,6 +857,58 @@ function migrate(db) {
|
|
|
783
857
|
ON runs(short_id);
|
|
784
858
|
`);
|
|
785
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Older builds used public or `harness:native` strings as foreign keys. Move
|
|
862
|
+
* those keys once to an opaque storage key while preserving the original
|
|
863
|
+
* native value in provider_session_id. We deliberately never split a native
|
|
864
|
+
* value on `:`: rows without a recorded provider ID remain untouched and are
|
|
865
|
+
* reported by normal preflight instead of guessed.
|
|
866
|
+
*/
|
|
867
|
+
function migrateSessionStorageKeys(db, dbPath) {
|
|
868
|
+
const rows = db.prepare("SELECT project_id, session_id, harness, provider_session_id, parent_session_id FROM sessions WHERE provider_session_id IS NOT NULL").all();
|
|
869
|
+
const changed = rows.map((row) => ({ ...row, next: storageSessionKey(row.harness, row.provider_session_id) })).filter((row) => row.session_id !== row.next);
|
|
870
|
+
if (changed.length === 0)
|
|
871
|
+
return;
|
|
872
|
+
// This is the only non-additive storage migration. `VACUUM INTO` captures
|
|
873
|
+
// a consistent SQLite snapshot (including WAL state) before keys change.
|
|
874
|
+
if (dbPath && fs.existsSync(dbPath)) {
|
|
875
|
+
const backup = `${dbPath}.pre-session-key-v1-${Date.now()}.sqlite`;
|
|
876
|
+
db.exec(`VACUUM INTO '${backup.replace(/'/g, "''")}'`);
|
|
877
|
+
}
|
|
878
|
+
const duplicate = new Set();
|
|
879
|
+
for (const row of changed) {
|
|
880
|
+
const key = `${row.project_id}\0${row.next}`;
|
|
881
|
+
if (duplicate.has(key) || db.prepare("SELECT 1 FROM sessions WHERE project_id = ? AND session_id = ? AND session_id <> ?").get(row.project_id, row.next, row.session_id)) {
|
|
882
|
+
throw new AppError("session_identity_migration_conflict", "Cannot migrate ambiguous harness/native Session identity", 1, { project_id: row.project_id, harness_id: row.harness, session_id: row.provider_session_id });
|
|
883
|
+
}
|
|
884
|
+
duplicate.add(key);
|
|
885
|
+
}
|
|
886
|
+
db.exec("BEGIN IMMEDIATE");
|
|
887
|
+
try {
|
|
888
|
+
// First update references. Session rows themselves are updated afterwards,
|
|
889
|
+
// so every dependent row remains resolvable throughout the transaction.
|
|
890
|
+
for (const row of changed) {
|
|
891
|
+
db.prepare("UPDATE work_sessions SET session_id = ? WHERE session_id = ? AND work_id IN (SELECT work_id FROM works WHERE project_id = ?)").run(row.next, row.session_id, row.project_id);
|
|
892
|
+
db.prepare("UPDATE flow_session_segments SET session_id = ? WHERE project_id = ? AND session_id = ?").run(row.next, row.project_id, row.session_id);
|
|
893
|
+
db.prepare("UPDATE usage SET session_id = ? WHERE project_id = ? AND session_id = ?").run(row.next, row.project_id, row.session_id);
|
|
894
|
+
const parameters = [row.next, row.project_id, row.session_id];
|
|
895
|
+
db.prepare("UPDATE hook_events SET session_id = ? WHERE project_id = ? AND session_id = ?").run(...parameters);
|
|
896
|
+
db.prepare("UPDATE flow_jobs SET worker_session_id = ? WHERE project_id = ? AND worker_session_id = ?").run(...parameters);
|
|
897
|
+
db.prepare("UPDATE merge_queue SET claimed_by_session_id = ? WHERE project_id = ? AND claimed_by_session_id = ?").run(...parameters);
|
|
898
|
+
db.prepare("UPDATE sessions SET parent_session_id = ? WHERE project_id = ? AND parent_session_id = ?").run(...parameters);
|
|
899
|
+
db.prepare("UPDATE sessions SET session_id = ? WHERE project_id = ? AND session_id = ?").run(...parameters);
|
|
900
|
+
}
|
|
901
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_harness_native ON sessions(project_id, harness, provider_session_id) WHERE provider_session_id IS NOT NULL");
|
|
902
|
+
db.exec("COMMIT");
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
db.exec("ROLLBACK");
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function storageSessionKey(harness, native) {
|
|
910
|
+
return `SES-${crypto.createHash("sha256").update(harness).update("\0").update(native).digest("hex").slice(0, 24)}`;
|
|
911
|
+
}
|
|
786
912
|
function migrateProjectScopedIdentity(db) {
|
|
787
913
|
const protocolColumns = db.prepare("PRAGMA table_info(protocols)").all();
|
|
788
914
|
const legacyGlobalPrimaryKey = protocolColumns.some((column) => column.name === "id" && column.pk === 1)
|