@deksden-com/dd-flow-cli 0.6.0 → 0.8.0-beta.135
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 +636 -0
- package/README.md +13 -1
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +108 -18
- package/dist/cli/run-cli.js +626 -49
- package/dist/domain/flow-contract.js +11 -0
- package/dist/domain/stage-catalog.js +22 -0
- package/dist/runtime/context.js +8 -2
- package/dist/schemas/code-review-decision.schema.json +26 -0
- package/dist/schemas/code-review-result.schema.json +14 -0
- package/dist/schemas/code-stage-report.schema.json +7 -2
- package/dist/schemas/code-verification.schema.json +14 -0
- package/dist/schemas/code-work-batch.schema.json +24 -0
- package/dist/schemas/code-work-result.schema.json +16 -0
- package/dist/schemas/engine-manifest.schema.json +22 -0
- package/dist/schemas/flow-contract.schema.json +6 -3
- package/dist/schemas/flow-run.schema.json +16 -122
- package/dist/schemas/mb-upgrade-migration-report.schema.json +3 -1
- package/dist/schemas/merge-stage-report-legacy-0.4.2.schema.json +24 -0
- package/dist/schemas/plan-aspect-map.schema.json +22 -0
- package/dist/schemas/plan-review-decision.schema.json +14 -0
- package/dist/schemas/plan-review-result.schema.json +42 -0
- package/dist/schemas/protocol-plan.schema.json +15 -182
- package/dist/schemas/run-engine-binding.schema.json +37 -0
- package/dist/schemas/stage-finish-input.schema.json +16 -2
- package/dist/schemas/stage-prompt.schema.json +4 -4
- package/dist/schemas/stage-report.schema.json +8 -7
- package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
- package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
- package/dist/schemas/vnext-specify.schema.json +45 -0
- package/dist/services/branch-context.js +1 -1
- package/dist/services/canon.js +15 -1
- package/dist/services/cleanup.js +8 -8
- package/dist/services/cli-operation-classifier.js +60 -8
- package/dist/services/code-checks.js +244 -0
- package/dist/services/compatibility-preflight.js +1 -1
- package/dist/services/config.js +7 -1
- package/dist/services/dashboard.js +14 -14
- package/dist/services/engines.js +408 -30
- package/dist/services/eval-snapshots.js +404 -0
- package/dist/services/hooks.js +775 -23
- package/dist/services/ids.js +16 -6
- package/dist/services/lanes.js +1 -5
- package/dist/services/merge-queue.js +53 -5
- package/dist/services/merge-worker.js +5 -6
- package/dist/services/migrations.js +307 -44
- package/dist/services/plan-runtime.js +5 -5
- package/dist/services/plans.js +5 -3
- package/dist/services/projects.js +4 -4
- package/dist/services/prompts.js +1 -1
- package/dist/services/protocols.js +31 -10
- package/dist/services/run-engine-bindings.js +157 -0
- package/dist/services/run-projection.js +49 -13
- package/dist/services/runs.js +525 -58
- package/dist/services/schema-validation.js +116 -2
- package/dist/services/sessions.js +51 -12
- package/dist/services/stage-blocker.js +57 -0
- package/dist/services/stage-context.js +90 -0
- package/dist/services/stage-lifecycle.js +288 -77
- package/dist/services/stage-pause.js +175 -0
- package/dist/services/stage-report-renderer.js +65 -0
- package/dist/services/status.js +8 -3
- package/dist/services/usage.js +526 -18
- package/dist/services/vnext-code-review.js +308 -0
- package/dist/services/vnext-code.js +616 -0
- package/dist/services/vnext-contracts.js +1 -0
- package/dist/services/vnext-execution-profile.js +27 -0
- package/dist/services/vnext-fanout.js +79 -0
- package/dist/services/vnext-plan-review.js +499 -0
- package/dist/services/vnext-plan.js +576 -0
- package/dist/services/vnext-protocolize.js +542 -0
- package/dist/services/vnext-specify.js +595 -0
- package/dist/services/vnext-workspace-policy.js +87 -0
- package/dist/services/work-registry.js +499 -0
- package/dist/services/worktrees.js +58 -37
- package/dist/storage/database.js +292 -42
- package/dist/storage/paths.js +47 -1
- package/package.json +12 -12
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
import { gitFacts } from "./runs.js";
|
|
5
|
+
const configRelativePath = path.join(".memory-bank", "dd-flow", "project-workspace.json");
|
|
6
|
+
/** The runtime reads explicit configuration; it never attempts to infer route from policy prose. */
|
|
7
|
+
export function loadVnextWorkspacePolicy(projectRoot) {
|
|
8
|
+
const file = path.join(projectRoot, configRelativePath);
|
|
9
|
+
if (!fs.existsSync(file)) {
|
|
10
|
+
throw new AppError("workspace_policy_missing", "vNext flow requires .memory-bank/dd-flow/project-workspace.json", 1, { file });
|
|
11
|
+
}
|
|
12
|
+
let value;
|
|
13
|
+
try {
|
|
14
|
+
value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
throw new AppError("workspace_policy_invalid", "Project workspace policy is not valid JSON", 1, { file, cause: String(error) });
|
|
18
|
+
}
|
|
19
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
20
|
+
throw new AppError("workspace_policy_invalid", "Project workspace policy must be an object", 1, { file });
|
|
21
|
+
}
|
|
22
|
+
const raw = value;
|
|
23
|
+
const workspace = raw.workspace;
|
|
24
|
+
const route = workspace?.route;
|
|
25
|
+
const integrationBranch = workspace?.integration_branch;
|
|
26
|
+
const template = workspace?.feature_branch_template;
|
|
27
|
+
const provisionStage = workspace?.provision_stage;
|
|
28
|
+
if (raw.schema_id !== "dd-flow/project-workspace@1" || !workspace || (route !== "feature_worktree" && route !== "integration_branch_direct") || typeof integrationBranch !== "string" || !integrationBranch || provisionStage !== "protocolize_start") {
|
|
29
|
+
throw new AppError("workspace_policy_invalid", "Project workspace policy does not match dd-flow/project-workspace@1", 1, { file });
|
|
30
|
+
}
|
|
31
|
+
if (route === "feature_worktree" && (typeof template !== "string" || !template.includes("<RUN>"))) {
|
|
32
|
+
throw new AppError("workspace_policy_invalid", "feature_worktree policy needs a branch template containing <RUN>", 1, { file });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
schema_id: "dd-flow/project-workspace@1",
|
|
36
|
+
workspace: {
|
|
37
|
+
route,
|
|
38
|
+
integration_branch: integrationBranch,
|
|
39
|
+
feature_branch_template: typeof template === "string" ? template : null,
|
|
40
|
+
provision_stage: "protocolize_start"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function vnextRunBranch(policy, run) {
|
|
45
|
+
if (policy.workspace.route !== "feature_worktree")
|
|
46
|
+
return null;
|
|
47
|
+
return policy.workspace.feature_branch_template
|
|
48
|
+
.replaceAll("<RUN>", run.short_id.toLowerCase())
|
|
49
|
+
.replaceAll("<slug>", safeSlug(run.slug));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The route receipt is the single source of truth after PROTOCOLIZE provisions
|
|
53
|
+
* the workspace. Every later mutating vNext stage calls this before it opens.
|
|
54
|
+
*/
|
|
55
|
+
export function requireVnextWorkspaceRoute(input) {
|
|
56
|
+
const file = path.join(input.runHome, "02-protocolize", "workspace-route.json");
|
|
57
|
+
let receipt;
|
|
58
|
+
try {
|
|
59
|
+
receipt = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new AppError("workspace_route_missing", `${input.stage} requires the PROTOCOLIZE workspace route receipt`, 1, { file, run_id: input.runId, cause: String(error) });
|
|
63
|
+
}
|
|
64
|
+
const policy = receipt.policy;
|
|
65
|
+
if (receipt.schema_id !== "dd-flow/vnext-workspace-route@1" || !policy?.provisioned || (policy.route !== "feature_worktree" && policy.route !== "integration_branch_direct")) {
|
|
66
|
+
throw new AppError("workspace_route_missing", `${input.stage} requires a valid provisioned workspace route`, 1, { file, run_id: input.runId });
|
|
67
|
+
}
|
|
68
|
+
const workspaceRoot = realPath(input.workspaceRoot);
|
|
69
|
+
if (policy.worktree_path && realPath(policy.worktree_path) !== workspaceRoot) {
|
|
70
|
+
throw new AppError("workspace_route_invalid", `${input.stage} RUN workspace differs from its frozen route`, 1, { run_id: input.runId, expected_workspace_root: policy.worktree_path, actual_workspace_root: workspaceRoot });
|
|
71
|
+
}
|
|
72
|
+
if (policy.route === "feature_worktree") {
|
|
73
|
+
if (!receipt.created || !receipt.bootstrap || !policy.worktree_path || !policy.feature_branch || !policy.base_ref || workspaceRoot === realPath(input.projectRoot)) {
|
|
74
|
+
throw new AppError("workspace_route_missing", `${input.stage} requires a provisioned feature worktree`, 1, { run_id: input.runId, workspace_root: workspaceRoot, policy });
|
|
75
|
+
}
|
|
76
|
+
const facts = gitFacts(workspaceRoot);
|
|
77
|
+
if (facts.status === "unavailable" || facts.branch !== policy.feature_branch || facts.head !== policy.base_ref) {
|
|
78
|
+
throw new AppError("workspace_route_invalid", `${input.stage} workspace no longer matches its frozen feature branch and base`, 1, { run_id: input.runId, workspace_root: workspaceRoot, expected: policy, actual: facts });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return policy;
|
|
82
|
+
}
|
|
83
|
+
function realPath(value) { return fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value); }
|
|
84
|
+
function safeSlug(value) {
|
|
85
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
86
|
+
return slug || "work";
|
|
87
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
|
|
6
|
+
import { validateSchema } from "./schema-validation.js";
|
|
7
|
+
import { resolveProjectRoot, resolveRunReferences, writeJsonAtomic } from "../storage/paths.js";
|
|
8
|
+
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
9
|
+
import { runCodeChecks } from "./code-checks.js";
|
|
10
|
+
import { nextWorkId } from "./ids.js";
|
|
11
|
+
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
12
|
+
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";
|
|
13
|
+
export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
|
|
14
|
+
/** Validate a proposed batch before PLAN accepts it, without registering Work. */
|
|
15
|
+
export function validateWorkBatchFile(file) {
|
|
16
|
+
const parsed = readJson(file);
|
|
17
|
+
if (!Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
18
|
+
throw new AppError("validation", "work batch requires non-empty works", 2);
|
|
19
|
+
const items = parsed.works.map((item) => validateItem(item, true));
|
|
20
|
+
const keys = new Set(items.map((item) => item.key));
|
|
21
|
+
if (keys.size !== items.length)
|
|
22
|
+
throw new AppError("validation", "work batch keys must be unique", 2);
|
|
23
|
+
for (const item of items)
|
|
24
|
+
for (const dependency of item.depends_on ?? [])
|
|
25
|
+
if (!keys.has(dependency))
|
|
26
|
+
throw new AppError("validation", "PLAN batch dependency must name a local key", 2, { key: item.key, dependency });
|
|
27
|
+
for (const item of items)
|
|
28
|
+
if (item.parent && !keys.has(item.parent))
|
|
29
|
+
throw new AppError("validation", "PLAN batch parent must name a local key", 2, { key: item.key, parent: item.parent });
|
|
30
|
+
assertNoCycles(items.map((item) => ({ id: item.key, dependencies: item.depends_on ?? [] })));
|
|
31
|
+
assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map((item) => ({ id: item.key, parent: item.parent })));
|
|
32
|
+
}
|
|
33
|
+
export function addWorkBatch(context, input) {
|
|
34
|
+
ensureWorkRegistry(context);
|
|
35
|
+
const parent = requireWork(context, input.parentWorkId);
|
|
36
|
+
if (parent.status !== "running")
|
|
37
|
+
throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
|
|
38
|
+
const parsed = readJson(input.file);
|
|
39
|
+
if (!Array.isArray(parsed.works) || parsed.works.length === 0)
|
|
40
|
+
throw new AppError("validation", "work batch requires non-empty works", 2);
|
|
41
|
+
const items = parsed.works.map((item) => validateItem(item));
|
|
42
|
+
const keys = new Set(items.map((item) => item.key));
|
|
43
|
+
if (keys.size !== items.length)
|
|
44
|
+
throw new AppError("validation", "work batch keys must be unique", 2);
|
|
45
|
+
const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
|
|
46
|
+
const existingIds = new Set(existing.map((row) => row.work_id));
|
|
47
|
+
for (const item of items)
|
|
48
|
+
for (const dependency of item.depends_on ?? [])
|
|
49
|
+
if (!keys.has(dependency) && !existingIds.has(dependency))
|
|
50
|
+
throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
|
|
51
|
+
for (const item of items)
|
|
52
|
+
if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
|
|
53
|
+
throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
|
|
54
|
+
const now = context.now();
|
|
55
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
56
|
+
try {
|
|
57
|
+
const ids = new Map(items.map((item) => [item.key, nextWorkId(context, parent.project_id, item.key)]));
|
|
58
|
+
const resolve = (value) => ids.get(value) ?? value;
|
|
59
|
+
const proposed = items.map((item) => ({ ...item, id: ids.get(item.key), parentId: item.parent ? resolve(item.parent) : parent.work_id, dependencies: (item.depends_on ?? []).map(resolve) }));
|
|
60
|
+
assertNoCycles(proposed.map((item) => ({ id: item.id, dependencies: item.dependencies })));
|
|
61
|
+
assertNoParentCycles(proposed.map((item) => ({ id: item.id, parent: item.parentId })));
|
|
62
|
+
for (const item of proposed)
|
|
63
|
+
context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', NULL, NULL, ?, ?, NULL)`, [item.id, parent.project_id, parent.run_id, item.parentId, item.task, item.launch_policy ?? "reuse_allowed", item.result_schema ?? null, item.payload ? JSON.stringify(item.payload) : null, JSON.stringify(item.dependencies), now, now]);
|
|
65
|
+
context.db.exec("COMMIT");
|
|
66
|
+
for (const item of proposed)
|
|
67
|
+
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" });
|
|
68
|
+
refreshRunWorkProjection(context, parent.project_id, parent.run_id);
|
|
69
|
+
return { ok: true, work_ids: Object.fromEntries(ids) };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
context.db.exec("ROLLBACK");
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function listWorks(context, input) {
|
|
77
|
+
ensureWorkRegistry(context);
|
|
78
|
+
if (!input.runId && !input.parentWorkId)
|
|
79
|
+
throw new AppError("usage", "work ls requires --run or --parent", 2);
|
|
80
|
+
const where = ["task IS NOT NULL"];
|
|
81
|
+
const params = [];
|
|
82
|
+
if (input.runId) {
|
|
83
|
+
where.push("run_id = ?");
|
|
84
|
+
params.push(input.runId);
|
|
85
|
+
}
|
|
86
|
+
if (input.parentWorkId) {
|
|
87
|
+
where.push("parent_work_id = ?");
|
|
88
|
+
params.push(input.parentWorkId);
|
|
89
|
+
}
|
|
90
|
+
if (input.status) {
|
|
91
|
+
where.push("status = ?");
|
|
92
|
+
params.push(input.status);
|
|
93
|
+
}
|
|
94
|
+
const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params);
|
|
95
|
+
const ready = (work) => isReady(context, work);
|
|
96
|
+
const filtered = input.ready ? rows.filter(ready) : rows;
|
|
97
|
+
return { works: filtered.slice(0, input.limit ?? filtered.length).map((work) => {
|
|
98
|
+
const isReadyNow = ready(work);
|
|
99
|
+
return { ...work, payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), ready: isReadyNow, ...(isReadyNow ? { start_command: workStartCommand(context, work) } : {}), ...(input.includeResults ? {} : { result: undefined }) };
|
|
100
|
+
}) };
|
|
101
|
+
}
|
|
102
|
+
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 } }; }
|
|
103
|
+
export function mutateWorkDeps(context, input) {
|
|
104
|
+
const work = requireWork(context, input.workId);
|
|
105
|
+
if (input.action === "list")
|
|
106
|
+
return { work_id: work.work_id, depends_on: parseDependencies(work) };
|
|
107
|
+
if (work.status !== "created")
|
|
108
|
+
throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
|
|
109
|
+
const dependencies = new Set(parseDependencies(work));
|
|
110
|
+
if (input.action === "clear")
|
|
111
|
+
dependencies.clear();
|
|
112
|
+
for (const id of input.on ?? []) {
|
|
113
|
+
const dependency = requireWork(context, id);
|
|
114
|
+
if (dependency.run_id !== work.run_id)
|
|
115
|
+
throw new AppError("validation", "Dependencies must stay in one RUN", 2);
|
|
116
|
+
if (input.action === "add")
|
|
117
|
+
dependencies.add(dependency.work_id);
|
|
118
|
+
else
|
|
119
|
+
dependencies.delete(dependency.work_id);
|
|
120
|
+
}
|
|
121
|
+
if (dependencies.has(work.work_id))
|
|
122
|
+
throw new AppError("validation", "Work cannot depend on itself", 2);
|
|
123
|
+
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) })));
|
|
124
|
+
context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
|
|
125
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
126
|
+
return { work_id: work.work_id, depends_on: [...dependencies] };
|
|
127
|
+
}
|
|
128
|
+
export function deleteWork(context, id) { const work = requireWork(context, 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}%`]))
|
|
129
|
+
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 }; }
|
|
130
|
+
export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
|
|
131
|
+
export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
|
|
132
|
+
export function startWork(context, id, input) {
|
|
133
|
+
ensureWorkRegistry(context);
|
|
134
|
+
const work = requireWork(context, id);
|
|
135
|
+
if (work.status !== "created")
|
|
136
|
+
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
|
|
137
|
+
if (!isReady(context, work)) {
|
|
138
|
+
const blockers = readinessBlockers(context, work);
|
|
139
|
+
const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
|
|
140
|
+
throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping write scopes are free", 2, { work_id: id, blockers, ready });
|
|
141
|
+
}
|
|
142
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
143
|
+
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
144
|
+
throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
|
|
145
|
+
let hookEventId = input.hookEventId;
|
|
146
|
+
if (!hookEventId) {
|
|
147
|
+
try {
|
|
148
|
+
hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
|
|
155
|
+
const started = startBoundWork(context, work, run, identity, hookEventId);
|
|
156
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
|
|
157
|
+
return started;
|
|
158
|
+
}
|
|
159
|
+
/** Binds the already-running coordinator of a stage to its trusted hook Session. */
|
|
160
|
+
export function bindRunningWorkSession(context, input) {
|
|
161
|
+
const work = requireWork(context, input.workId);
|
|
162
|
+
if (work.status !== "running")
|
|
163
|
+
throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
|
|
164
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
165
|
+
const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
|
|
166
|
+
const active = 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", [work.work_id]);
|
|
167
|
+
if (active) {
|
|
168
|
+
if (active.session_id !== identity.sessionId)
|
|
169
|
+
throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
|
|
170
|
+
context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = COALESCE(?, result_path), updated_at = ? WHERE id = ?", [input.promptPath, input.resultPath ?? null, context.now(), active.id]);
|
|
171
|
+
return { work_session_id: active.id, session_id: identity.sessionId, reused: true };
|
|
172
|
+
}
|
|
173
|
+
const now = context.now();
|
|
174
|
+
const id = `WS-${crypto.randomUUID()}`;
|
|
175
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
176
|
+
try {
|
|
177
|
+
bindSession(context, work, run, identity, now);
|
|
178
|
+
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]);
|
|
179
|
+
context.db.exec("COMMIT");
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
context.db.exec("ROLLBACK");
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
186
|
+
return { work_session_id: id, session_id: identity.sessionId };
|
|
187
|
+
}
|
|
188
|
+
/** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
|
|
189
|
+
export function bindStageCoordinatorWork(context, input) {
|
|
190
|
+
const work = requireWork(context, input.workId);
|
|
191
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
192
|
+
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 } : {}) });
|
|
193
|
+
const binding = bindRunningWorkSession(context, input);
|
|
194
|
+
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
|
|
195
|
+
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
196
|
+
return binding;
|
|
197
|
+
}
|
|
198
|
+
/** Starts a created coordinator Work from its trusted `stage start` event. */
|
|
199
|
+
export function startStageCoordinatorWork(context, input) {
|
|
200
|
+
const work = requireWork(context, input.workId);
|
|
201
|
+
if (work.status !== "created")
|
|
202
|
+
throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
203
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
204
|
+
if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
|
|
205
|
+
throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
|
|
206
|
+
const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root });
|
|
207
|
+
const binding = startBoundWork(context, work, run, identity, input.hookEventId);
|
|
208
|
+
const sessionId = String(binding.session_binding.session_id ?? "");
|
|
209
|
+
context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
|
|
210
|
+
refreshRunSessionProjection(context, work.project_id, work.run_id);
|
|
211
|
+
return binding;
|
|
212
|
+
}
|
|
213
|
+
/** Stage entry points call this only after their own trusted hook claim. */
|
|
214
|
+
export function startBoundWork(context, work, run, identity, hookEventId) {
|
|
215
|
+
if (work.status !== "created")
|
|
216
|
+
throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
|
|
217
|
+
const now = context.now();
|
|
218
|
+
const linkId = `WS-${crypto.randomUUID()}`;
|
|
219
|
+
const directory = path.join(requireRunHome(run), "works", work.work_id);
|
|
220
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
221
|
+
const promptPath = path.join(directory, "prompt.md");
|
|
222
|
+
const resultPath = path.join(directory, "result.json");
|
|
223
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
224
|
+
try {
|
|
225
|
+
bindSession(context, work, run, identity, now);
|
|
226
|
+
const claimed = context.db.run("UPDATE works SET status = 'running', started_at = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
|
|
227
|
+
if (claimed.changes !== 1)
|
|
228
|
+
throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
|
|
229
|
+
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]);
|
|
230
|
+
context.db.exec("COMMIT");
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
context.db.exec("ROLLBACK");
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
|
|
237
|
+
const prompt = renderWorkerPrompt(context, work, run, dependencyResults, resultPath);
|
|
238
|
+
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 });
|
|
239
|
+
fs.writeFileSync(promptPath, prompt);
|
|
240
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
241
|
+
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.sessionId } };
|
|
242
|
+
}
|
|
243
|
+
export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
|
|
244
|
+
export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
|
|
245
|
+
export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
|
|
246
|
+
export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
|
|
247
|
+
throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
|
|
248
|
+
const source = path.join(directory, file);
|
|
249
|
+
if (fs.existsSync(source))
|
|
250
|
+
fs.renameSync(source, path.join(archive, file));
|
|
251
|
+
} context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
|
|
252
|
+
async function settle(context, id, status, result, progress) {
|
|
253
|
+
const work = requireWork(context, id);
|
|
254
|
+
id = work.work_id;
|
|
255
|
+
if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
|
|
256
|
+
throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
|
|
257
|
+
if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running') LIMIT 1", [id]))
|
|
258
|
+
throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
|
|
259
|
+
const run = requireRun(context, work.project_id, work.run_id);
|
|
260
|
+
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]);
|
|
261
|
+
if (work.status === "running" && !link)
|
|
262
|
+
throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
|
|
263
|
+
let receipts = [];
|
|
264
|
+
if (status === "completed") {
|
|
265
|
+
validateWorkResult(work, result, run.project_root, run.workspace_root, run.id, link?.result_path ?? null);
|
|
266
|
+
const packet = codePacket(work);
|
|
267
|
+
if (packet) {
|
|
268
|
+
validateChangedPaths(packet, result);
|
|
269
|
+
const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
|
|
270
|
+
receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: packet.checks.filter((check) => check.run_at === "work"), ...(progress ? { progress } : {}) });
|
|
271
|
+
const failed = receipts.filter((receipt) => receipt.status === "failed");
|
|
272
|
+
if (failed.length)
|
|
273
|
+
throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const now = context.now();
|
|
277
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
278
|
+
try {
|
|
279
|
+
context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
|
|
280
|
+
if (link) {
|
|
281
|
+
if (link.result_path && work.parent_work_id !== null)
|
|
282
|
+
fs.writeFileSync(link.result_path, result);
|
|
283
|
+
context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
|
|
284
|
+
const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
|
|
285
|
+
if (!stillRunning)
|
|
286
|
+
context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, link.session_id]);
|
|
287
|
+
}
|
|
288
|
+
context.db.exec("COMMIT");
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
context.db.exec("ROLLBACK");
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
refreshRunWorkProjection(context, work.project_id, work.run_id);
|
|
295
|
+
const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
|
|
296
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null });
|
|
297
|
+
for (const ready of newlyReady)
|
|
298
|
+
appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
|
|
299
|
+
return { ok: true, work_id: id, status, checks: receipts, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
|
|
300
|
+
}
|
|
301
|
+
function validateChangedPaths(packet, result) {
|
|
302
|
+
const changed = JSON.parse(result).changed_paths;
|
|
303
|
+
if (!Array.isArray(changed))
|
|
304
|
+
return;
|
|
305
|
+
const outside = changed.filter((item) => typeof item === "string" && !packet.write_scope.some((scope) => item === scope || item.startsWith(`${scope}/`)));
|
|
306
|
+
if (outside.length)
|
|
307
|
+
throw new AppError("write_scope_violation", "CODE Work result declares paths outside its accepted write scope", 2, { outside, write_scope: packet.write_scope });
|
|
308
|
+
}
|
|
309
|
+
function bindSession(context, work, run, identity, now) {
|
|
310
|
+
const priorWorkSession = context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.work_id])?.session_id ?? null;
|
|
311
|
+
const inferredParentSession = work.parent_work_id
|
|
312
|
+
? 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
|
|
313
|
+
: priorWorkSession && priorWorkSession !== identity.sessionId ? priorWorkSession : null;
|
|
314
|
+
const parentSession = identity.parentSessionId ?? inferredParentSession;
|
|
315
|
+
if (work.parent_work_id && !parentSession)
|
|
316
|
+
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 });
|
|
317
|
+
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])))
|
|
318
|
+
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 });
|
|
319
|
+
const existing = context.db.get("SELECT session_id, parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
|
|
320
|
+
if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
|
|
321
|
+
throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
|
|
322
|
+
context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_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), 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, 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]);
|
|
323
|
+
reactivateBoundSession(context, work.project_id, identity.sessionId, now);
|
|
324
|
+
}
|
|
325
|
+
function reactivateBoundSession(context, projectId, sessionId, now) {
|
|
326
|
+
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]);
|
|
327
|
+
}
|
|
328
|
+
function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, resultPath) {
|
|
329
|
+
if (!work.result_schema)
|
|
330
|
+
return;
|
|
331
|
+
if (!resultPath)
|
|
332
|
+
throw new AppError("runtime_missing", "Work result path is unavailable", 1, { work_id: work.work_id });
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(result);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
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 });
|
|
339
|
+
}
|
|
340
|
+
fs.writeFileSync(resultPath, result);
|
|
341
|
+
validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: resultPath, projectRoot, runId });
|
|
342
|
+
if (work.result_schema === "dd-flow/code-work-result@2")
|
|
343
|
+
validateCodeWorkResult(work, parsed, workspaceRoot);
|
|
344
|
+
if (work.result_schema === "dd-flow/code-review-result@1")
|
|
345
|
+
validateCodeReviewResultIdentity(work, parsed);
|
|
346
|
+
}
|
|
347
|
+
function validateCodeWorkResult(work, value, projectRoot) {
|
|
348
|
+
const result = value;
|
|
349
|
+
if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
|
|
350
|
+
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 ?? [] });
|
|
351
|
+
const packet = codePacket(work);
|
|
352
|
+
if (!packet)
|
|
353
|
+
return;
|
|
354
|
+
const documentUpdates = (packet.document_updates ?? []);
|
|
355
|
+
const changed = new Set(result.changed_paths ?? []);
|
|
356
|
+
const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
|
|
357
|
+
if (missingDocuments.length)
|
|
358
|
+
throw new AppError("document_update_missing", "CODE Work did not materialize every document update assigned by PLAN", 2, { work_id: work.work_id, missing_paths: missingDocuments });
|
|
359
|
+
const unchangedDocuments = documentUpdates.filter((entry) => { const file = path.join(projectRoot, entry.path); if (!fs.existsSync(file) || !fs.statSync(file).isFile())
|
|
360
|
+
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);
|
|
361
|
+
if (unchangedDocuments.length)
|
|
362
|
+
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 });
|
|
363
|
+
const assigned = packet.repair?.review_finding_ids ?? [];
|
|
364
|
+
if (assigned.length) {
|
|
365
|
+
const resolved = new Set(result.resolved_finding_refs ?? []);
|
|
366
|
+
const missing = assigned.filter((finding) => !resolved.has(finding));
|
|
367
|
+
const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
|
|
368
|
+
if (missing.length || unexpected.length)
|
|
369
|
+
throw new AppError("review_repair_incomplete", "Review repair must explicitly resolve exactly its assigned finding references", 2, { work_id: work.work_id, missing, unexpected });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function renderWorkerPrompt(context, work, run, dependencies, resultPath) {
|
|
373
|
+
const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
374
|
+
const packet = codePacket(work);
|
|
375
|
+
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 only this Work's semantic_spine.acceptance_contribution, task, write_scope, and declared checks. A criterion may name another ordered Work's UI, documentation, or evidence surface; do not fail merely because that surface is outside this Work's write_scope.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<write_scope>", ...packet.write_scope.map((item) => `- ${item}`), "</write_scope>", "", ...(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>", ""] : [];
|
|
376
|
+
if (packet)
|
|
377
|
+
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>", "", "<contract_failure>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. If a required path is missing from write_scope, do not report success: fail this Work with that exact path and the retained receipt so the coordinator can correct the packet.", "</contract_failure>", "");
|
|
378
|
+
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>", "", "<workspace_contract>", `All project reads and writes belong under ${run.workspace_root}. Project root is only the stable lifecycle identity for dd-flow commands. Do not create, switch, merge or delete branches/worktrees.`, "</workspace_contract>", "", ...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), `Write it to ${resultPath}.`, "</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 for a contractual scope conflict where a required repair path lies outside this Work's write_scope. Quote the exact receipt plus the error or missing path. A project check failure inside this Work's scope must be fixed before Finish.", "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: ${command} work finish ${work.work_id} --result-file ${JSON.stringify(resultPath)} --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or scope-contract case: ${command} work fail ${work.work_id} --reason "receipt path + exact error or missing write_scope path" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
379
|
+
}
|
|
380
|
+
function resultSchemaGuidance(work) {
|
|
381
|
+
const schema = work.result_schema;
|
|
382
|
+
if (schema === "dd-flow/code-work-result@2")
|
|
383
|
+
return ["Use this complete minimal shape. For a review repair, also include resolved_finding_refs with exactly the finding references assigned in repair_context:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence"] }], deviations: [], blockers: [], resolved_finding_refs: [] }, null, 2), "```"];
|
|
384
|
+
if (schema === "dd-flow/code-review-result@1") {
|
|
385
|
+
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), "```"];
|
|
386
|
+
}
|
|
387
|
+
if (schema !== "dd-flow/plan-review-result@1")
|
|
388
|
+
return [];
|
|
389
|
+
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), "```"];
|
|
390
|
+
}
|
|
391
|
+
export function validateCodeReviewResultIdentity(work, value) {
|
|
392
|
+
const group = codeReviewGroup(work);
|
|
393
|
+
if (!group)
|
|
394
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer Work has no assigned review group", 2, { work_id: work.work_id });
|
|
395
|
+
const result = value;
|
|
396
|
+
const aspectIds = (result.aspects ?? []).map((item) => item.aspect_id ?? "");
|
|
397
|
+
const expected = new Set(group.aspect_ids);
|
|
398
|
+
const missing = group.aspect_ids.filter((id) => !aspectIds.includes(id));
|
|
399
|
+
const unexpected = aspectIds.filter((id) => !expected.has(id));
|
|
400
|
+
if (new Set(aspectIds).size !== aspectIds.length || missing.length || unexpected.length) {
|
|
401
|
+
throw new AppError("review_evidence_invalid", "CODE reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
|
|
402
|
+
}
|
|
403
|
+
const findingIds = (result.findings ?? []).map((item) => item.finding_id ?? "");
|
|
404
|
+
const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
|
|
405
|
+
const wrongAspect = (result.findings ?? []).filter((item) => !item.aspect_id || !expected.has(item.aspect_id)).map((item) => item.finding_id ?? "");
|
|
406
|
+
if (new Set(findingIds).size !== findingIds.length || invalid.length || wrongAspect.length) {
|
|
407
|
+
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 });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function codeReviewGroup(work) {
|
|
411
|
+
const payload = parsePayload(work);
|
|
412
|
+
const group = payload?.group;
|
|
413
|
+
if (!group || typeof group !== "object" || Array.isArray(group))
|
|
414
|
+
return null;
|
|
415
|
+
const value = group;
|
|
416
|
+
return typeof value.key === "string" && Array.isArray(value.aspect_ids) && value.aspect_ids.every((item) => typeof item === "string")
|
|
417
|
+
? { key: value.key, aspect_ids: value.aspect_ids }
|
|
418
|
+
: null;
|
|
419
|
+
}
|
|
420
|
+
function requireWork(context, id) { ensureWorkRegistry(context); const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?`, [id]); if (exact)
|
|
421
|
+
return exact; if (!/^WRK-\d{3,}$/.test(id))
|
|
422
|
+
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)
|
|
423
|
+
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]; }
|
|
424
|
+
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_home_path, 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_home_path)
|
|
425
|
+
throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: runId }); return run; }
|
|
426
|
+
function requireRunHome(run) { if (!run.run_home_path)
|
|
427
|
+
throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: run.id }); return run.run_home_path; }
|
|
428
|
+
function parseDependencies(work) { try {
|
|
429
|
+
const value = JSON.parse(work.depends_on_json);
|
|
430
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return [];
|
|
434
|
+
} }
|
|
435
|
+
function isReady(context, work) { return work.status === "created" && readinessBlockers(context, work).length === 0; }
|
|
436
|
+
function readinessBlockers(context, work) {
|
|
437
|
+
const blockers = parseDependencies(work).flatMap((id) => { const dependency = context.db.get("SELECT status FROM works WHERE work_id = ?", [id]); return dependency?.status === "completed" ? [] : [{ kind: "dependency", work_id: id, status: dependency?.status ?? "missing" }]; });
|
|
438
|
+
const scope = workWriteScope(work);
|
|
439
|
+
if (scope.length === 0)
|
|
440
|
+
return blockers;
|
|
441
|
+
for (const running of context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND work_id != ?`, [work.project_id, work.run_id, work.work_id])) {
|
|
442
|
+
const overlap = overlappingScopes(scope, workWriteScope(running));
|
|
443
|
+
if (overlap.length)
|
|
444
|
+
blockers.push({ kind: "write_scope", work_id: running.work_id, paths: overlap });
|
|
445
|
+
}
|
|
446
|
+
return blockers;
|
|
447
|
+
}
|
|
448
|
+
function workWriteScope(work) { const payload = parsePayload(work); return Array.isArray(payload?.write_scope) ? payload.write_scope.filter((item) => typeof item === "string").map((item) => item.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "")) : []; }
|
|
449
|
+
function overlappingScopes(left, right) { const overlap = new Set(); for (const a of left)
|
|
450
|
+
for (const b of right)
|
|
451
|
+
if (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`))
|
|
452
|
+
overlap.add(a.length <= b.length ? a : b); return [...overlap]; }
|
|
453
|
+
function validateItem(value, requireExecutionContext = false) { if (!value || typeof value !== "object" || Array.isArray(value))
|
|
454
|
+
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())
|
|
455
|
+
throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@4"; if (requireExecutionContext && !code)
|
|
456
|
+
throw new AppError("validation", "CODE batch requires code-work-packet@4 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "write_scope", "checks", "provides_checks", "stop_conditions"] : [])
|
|
457
|
+
if (!Array.isArray(item[key]) || (key !== "provides_checks" && item[key].length === 0))
|
|
458
|
+
throw new AppError("validation", `CODE work item requires ${key === "provides_checks" ? "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")))
|
|
459
|
+
throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
|
|
460
|
+
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")
|
|
461
|
+
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))
|
|
462
|
+
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 } : {}) }; }
|
|
463
|
+
function readJson(file) { try {
|
|
464
|
+
return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
|
|
468
|
+
} }
|
|
469
|
+
export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_home_path FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_home_path) {
|
|
470
|
+
const obsolete = path.join(run.run_home_path, "work.json");
|
|
471
|
+
if (fs.existsSync(obsolete))
|
|
472
|
+
fs.rmSync(obsolete);
|
|
473
|
+
} }
|
|
474
|
+
export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
|
|
475
|
+
function parsePayload(work) { if (!work.payload_json)
|
|
476
|
+
return null; try {
|
|
477
|
+
const value = JSON.parse(work.payload_json);
|
|
478
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
} }
|
|
483
|
+
function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@4")
|
|
484
|
+
return null; return value; }
|
|
485
|
+
function assertNoCycles(nodes) { const local = new Map(nodes.map((node) => [node.id, node.dependencies.filter((dependency) => nodes.some((candidate) => candidate.id === dependency))])); const active = new Set(); const done = new Set(); const visit = (id) => { if (active.has(id))
|
|
486
|
+
throw new AppError("validation", "Work dependencies contain a cycle", 2); if (done.has(id))
|
|
487
|
+
return; active.add(id); for (const dependency of local.get(id) ?? [])
|
|
488
|
+
visit(dependency); active.delete(id); done.add(id); }; for (const id of local.keys())
|
|
489
|
+
visit(id); }
|
|
490
|
+
function assertNoParentCycles(nodes) { const parents = new Map(nodes.map((node) => [node.id, node.parent])); for (const node of nodes) {
|
|
491
|
+
const seen = new Set([node.id]);
|
|
492
|
+
let parent = node.parent;
|
|
493
|
+
while (parents.has(parent)) {
|
|
494
|
+
if (seen.has(parent))
|
|
495
|
+
throw new AppError("validation", "Work parents contain a cycle", 2);
|
|
496
|
+
seen.add(parent);
|
|
497
|
+
parent = parents.get(parent);
|
|
498
|
+
}
|
|
499
|
+
} }
|