@deksden-com/dd-flow-cli 0.8.0 → 0.9.0-beta.0

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.
@@ -6,11 +6,22 @@ import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHo
6
6
  import { validateSchema } from "./schema-validation.js";
7
7
  import { resolveProjectRoot, resolveRunReferences, writeJsonAtomic } from "../storage/paths.js";
8
8
  import { refreshRunSessionProjection } from "./run-projection.js";
9
- import { runCodeChecks } from "./code-checks.js";
10
- import { nextWorkId } from "./ids.js";
9
+ import { readCodeCheckProfile, runCodeChecks } from "./code-checks.js";
10
+ import { nextWorkId, nextWorkIds } from "./ids.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ import { flowCommand } from "./stage-pause.js";
12
13
  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
14
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
15
+ export function createChildWork(context, input) {
16
+ const parent = requireWork(context, input.parentWorkId);
17
+ if (parent.status !== "running")
18
+ throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
19
+ const id = nextWorkId(context, parent.project_id, input.slug);
20
+ const now = context.now();
21
+ context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
22
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
23
+ return requireWork(context, id);
24
+ }
14
25
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
15
26
  export function validateWorkBatchFile(file) {
16
27
  const parsed = readJson(file);
@@ -54,7 +65,8 @@ export function addWorkBatch(context, input) {
54
65
  const now = context.now();
55
66
  context.db.exec("BEGIN IMMEDIATE");
56
67
  try {
57
- const ids = new Map(items.map((item) => [item.key, nextWorkId(context, parent.project_id, item.key)]));
68
+ const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
69
+ const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
58
70
  const resolve = (value) => ids.get(value) ?? value;
59
71
  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
72
  assertNoCycles(proposed.map((item) => ({ id: item.id, dependencies: item.dependencies })));
@@ -128,7 +140,13 @@ export function mutateWorkDeps(context, input) {
128
140
  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
141
  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
142
  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`; }
143
+ /**
144
+ * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
145
+ * Fan-out workers run in independently spawned harnesses where the adapter's
146
+ * PATH is not inherited; using the shared lifecycle command keeps them on the
147
+ * same captured runtime as their parent stage.
148
+ */
149
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
132
150
  export function startWork(context, id, input) {
133
151
  ensureWorkRegistry(context);
134
152
  const work = requireWork(context, id);
@@ -227,7 +245,7 @@ export function startStageCoordinatorWork(context, input) {
227
245
  const run = requireRun(context, work.project_id, work.run_id);
228
246
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
229
247
  throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
230
- const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root });
248
+ const identity = 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 } : {}) });
231
249
  const binding = startBoundWork(context, work, run, identity, input.hookEventId);
232
250
  const sessionId = String(binding.session_binding.session_id ?? "");
233
251
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
@@ -247,6 +265,8 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
247
265
  context.db.exec("BEGIN IMMEDIATE");
248
266
  try {
249
267
  bindSession(context, work, run, identity, now);
268
+ if (work.parent_work_id && work.launch_policy === "reuse_allowed")
269
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, work.work_id]);
250
270
  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]);
251
271
  if (claimed.changes !== 1)
252
272
  throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
@@ -265,6 +285,10 @@ export function startBoundWork(context, work, run, identity, hookEventId) {
265
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.sessionId } };
266
286
  }
267
287
  export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
288
+ /** Structured fan-in closes a parent after its Session was handed to a child. */
289
+ 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]))
291
+ 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 }; }
268
292
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
269
293
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
270
294
  export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
@@ -290,10 +314,14 @@ async function settle(context, id, status, result, progress) {
290
314
  validateWorkResult(work, result, run.project_root, run.workspace_root, run.id, link?.result_path ?? null);
291
315
  const packet = codePacket(work);
292
316
  if (packet) {
317
+ // CODE packets are projected from a PLAN that already validated this
318
+ // profile. Check it even when the Work has only raw focused checks, so a
319
+ // worker cannot complete after downgrading the frozen project contract.
320
+ readCodeCheckProfile(run.workspace_root);
293
321
  coordinationDrift = plannedAreaDrift(packet, result);
294
322
  const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
295
323
  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 } : {}) });
296
- const failed = receipts.filter((receipt) => receipt.status === "failed");
324
+ const failed = receipts.filter((receipt) => receipt.status !== "passed");
297
325
  if (failed.length)
298
326
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
299
327
  }
@@ -393,7 +421,7 @@ function validateCodeWorkResult(work, value, projectRoot) {
393
421
  }
394
422
  }
395
423
  function renderWorkerPrompt(context, work, run, dependencies, resultPath) {
396
- const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
424
+ const command = flowCommand(context);
397
425
  const packet = codePacket(work);
398
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>", ""] : [];
399
427
  if (packet)
@@ -58,13 +58,21 @@ export function ensureVnextWorkStorage(db) {
58
58
  work_id TEXT,
59
59
  scope TEXT NOT NULL,
60
60
  declaration_id TEXT NOT NULL,
61
+ check_refs_json TEXT NOT NULL DEFAULT '[]',
62
+ gate TEXT NOT NULL DEFAULT 'work',
61
63
  command TEXT NOT NULL,
64
+ input_hash TEXT NOT NULL DEFAULT '',
65
+ verification_epoch TEXT NOT NULL DEFAULT '',
62
66
  status TEXT NOT NULL,
63
67
  exit_code INTEGER,
64
68
  stdout_path TEXT NOT NULL,
65
69
  stderr_path TEXT NOT NULL,
66
70
  receipt_path TEXT NOT NULL,
67
71
  workspace_fingerprint TEXT NOT NULL,
72
+ before_fingerprint TEXT NOT NULL DEFAULT '',
73
+ after_fingerprint TEXT NOT NULL DEFAULT '',
74
+ profile_hash TEXT,
75
+ mutation_paths_json TEXT NOT NULL DEFAULT '[]',
68
76
  artifacts_json TEXT NOT NULL,
69
77
  started_at TEXT NOT NULL,
70
78
  finished_at TEXT NOT NULL,
@@ -474,6 +482,56 @@ function migrate(db) {
474
482
  CREATE INDEX IF NOT EXISTS idx_check_receipts_run
475
483
  ON check_receipts(project_id, run_id, started_at);
476
484
 
485
+ CREATE TABLE IF NOT EXISTS merge_requests (
486
+ merge_request_id TEXT PRIMARY KEY,
487
+ project_id TEXT NOT NULL,
488
+ run_id TEXT NOT NULL,
489
+ executor_work_id TEXT NOT NULL,
490
+ protocol_ids_json TEXT NOT NULL,
491
+ source_workspace TEXT NOT NULL,
492
+ source_branch TEXT NOT NULL,
493
+ source_commit TEXT NOT NULL,
494
+ target_workspace TEXT NOT NULL,
495
+ target_branch TEXT NOT NULL,
496
+ enqueue_target_head TEXT,
497
+ execution_target_head TEXT,
498
+ integration_commit TEXT,
499
+ execution_route TEXT NOT NULL,
500
+ status TEXT NOT NULL,
501
+ dispatch_owner TEXT,
502
+ dispatch_lease_token TEXT,
503
+ dispatch_lease_expires_at TEXT,
504
+ lock_acquired_at TEXT,
505
+ checkpoint TEXT NOT NULL DEFAULT 'queued',
506
+ profile_hash TEXT,
507
+ adapter_receipt_json TEXT,
508
+ result_json TEXT,
509
+ last_error_json TEXT,
510
+ created_at TEXT NOT NULL,
511
+ updated_at TEXT NOT NULL,
512
+ completed_at TEXT,
513
+ FOREIGN KEY(project_id, run_id) REFERENCES runs(project_id, id),
514
+ FOREIGN KEY(executor_work_id) REFERENCES works(work_id)
515
+ );
516
+ CREATE INDEX IF NOT EXISTS idx_merge_requests_fifo
517
+ ON merge_requests(project_id, status, created_at, merge_request_id);
518
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_run
519
+ ON merge_requests(project_id, run_id);
520
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_active_project
521
+ ON merge_requests(project_id)
522
+ WHERE status IN ('active', 'waiting_user', 'action_required', 'recovery_required');
523
+
524
+ CREATE TABLE IF NOT EXISTS merge_servers (
525
+ server_id TEXT PRIMARY KEY,
526
+ profile_id TEXT NOT NULL,
527
+ status TEXT NOT NULL,
528
+ pid INTEGER,
529
+ started_at TEXT NOT NULL,
530
+ heartbeat_at TEXT NOT NULL,
531
+ stopped_at TEXT,
532
+ journal_path TEXT NOT NULL
533
+ );
534
+
477
535
  CREATE TABLE IF NOT EXISTS flow_run_flag_mutations (
478
536
  project_id TEXT NOT NULL,
479
537
  run_id TEXT NOT NULL,
@@ -685,6 +743,14 @@ function migrate(db) {
685
743
  ensureColumn(db, "check_receipts", "declaration_id", "ALTER TABLE check_receipts ADD COLUMN declaration_id TEXT NOT NULL DEFAULT 'CHK-LEGACY'");
686
744
  ensureColumn(db, "check_receipts", "workspace_fingerprint", "ALTER TABLE check_receipts ADD COLUMN workspace_fingerprint TEXT NOT NULL DEFAULT ''");
687
745
  ensureColumn(db, "check_receipts", "artifacts_json", "ALTER TABLE check_receipts ADD COLUMN artifacts_json TEXT NOT NULL DEFAULT '[]'");
746
+ ensureColumn(db, "check_receipts", "check_refs_json", "ALTER TABLE check_receipts ADD COLUMN check_refs_json TEXT NOT NULL DEFAULT '[]'");
747
+ ensureColumn(db, "check_receipts", "gate", "ALTER TABLE check_receipts ADD COLUMN gate TEXT NOT NULL DEFAULT 'work'");
748
+ ensureColumn(db, "check_receipts", "input_hash", "ALTER TABLE check_receipts ADD COLUMN input_hash TEXT NOT NULL DEFAULT ''");
749
+ ensureColumn(db, "check_receipts", "verification_epoch", "ALTER TABLE check_receipts ADD COLUMN verification_epoch TEXT NOT NULL DEFAULT ''");
750
+ ensureColumn(db, "check_receipts", "before_fingerprint", "ALTER TABLE check_receipts ADD COLUMN before_fingerprint TEXT NOT NULL DEFAULT ''");
751
+ ensureColumn(db, "check_receipts", "after_fingerprint", "ALTER TABLE check_receipts ADD COLUMN after_fingerprint TEXT NOT NULL DEFAULT ''");
752
+ ensureColumn(db, "check_receipts", "profile_hash", "ALTER TABLE check_receipts ADD COLUMN profile_hash TEXT");
753
+ ensureColumn(db, "check_receipts", "mutation_paths_json", "ALTER TABLE check_receipts ADD COLUMN mutation_paths_json TEXT NOT NULL DEFAULT '[]'");
688
754
  ensureColumn(db, "hook_events", "event_key", "ALTER TABLE hook_events ADD COLUMN event_key TEXT");
689
755
  ensureColumn(db, "hook_events", "match_key", "ALTER TABLE hook_events ADD COLUMN match_key TEXT");
690
756
  ensureColumn(db, "hook_events", "claimed_at", "ALTER TABLE hook_events ADD COLUMN claimed_at TEXT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-beta.0",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "ajv": "^8.20.0",
30
+ "shell-quote": "^1.10.0",
30
31
  "smol-toml": "^1.6.1",
31
32
  "yaml": "^2.9.0"
32
33
  },