@deksden-com/dd-flow-cli 0.4.2 → 0.5.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +27 -4
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/help.js +14 -3
  4. package/dist/cli/run-cli.js +32 -16
  5. package/dist/domain/flow-contract.js +81 -2
  6. package/dist/domain/validation.js +56 -28
  7. package/dist/protocol/local-files.js +1 -16
  8. package/dist/schemas/code-stage-report.schema.json +2 -2
  9. package/dist/schemas/flow-contract.schema.json +150 -94
  10. package/dist/schemas/flow-run.schema.json +129 -23
  11. package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
  12. package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
  13. package/dist/schemas/merge-stage-report.schema.json +2 -2
  14. package/dist/schemas/plan-stage-report.schema.json +38 -335
  15. package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
  16. package/dist/schemas/protocol-plan.schema.json +197 -0
  17. package/dist/schemas/release-impact.schema.json +9 -5
  18. package/dist/schemas/session-usage.schema.json +16 -0
  19. package/dist/schemas/stage-finish-input.schema.json +20 -0
  20. package/dist/schemas/stage-prompt.schema.json +31 -0
  21. package/dist/schemas/stage-report.schema.json +20 -0
  22. package/dist/schemas/stage-start-response.schema.json +29 -0
  23. package/dist/schemas/timeline-event.schema.json +29 -0
  24. package/dist/schemas/worktrunk-workspace.schema.json +19 -0
  25. package/dist/services/branch-context.js +9 -4
  26. package/dist/services/dashboard.js +51 -26
  27. package/dist/services/engines.js +84 -18
  28. package/dist/services/hooks.js +80 -246
  29. package/dist/services/memory-permissions.js +77 -69
  30. package/dist/services/plan-runtime.js +124 -0
  31. package/dist/services/plans.js +22 -84
  32. package/dist/services/projects.js +2 -1
  33. package/dist/services/prompts.js +26 -21
  34. package/dist/services/protocols.js +29 -25
  35. package/dist/services/run-projection.js +77 -11
  36. package/dist/services/runs.js +95 -61
  37. package/dist/services/schema-validation.js +168 -7
  38. package/dist/services/sessions.js +132 -68
  39. package/dist/services/stage-lifecycle.js +572 -0
  40. package/dist/services/tooling.js +285 -0
  41. package/dist/services/usage.js +183 -30
  42. package/dist/services/version-status.js +1 -1
  43. package/dist/services/worktrees.js +88 -39
  44. package/dist/storage/database.js +72 -30
  45. package/dist/storage/paths.js +0 -9
  46. package/package.json +14 -13
  47. package/tools/worktrunk-manifest.json +34 -0
  48. package/dist/schemas/flow-run-index-v3.schema.json +0 -203
  49. package/dist/schemas/flow-run-index.schema.json +0 -175
@@ -1,9 +1,65 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { Ajv } from "ajv/dist/ajv.js";
5
6
  import { normalizeFlowContract } from "../domain/flow-contract.js";
6
7
  import { AppError } from "../shared/errors.js";
8
+ export function captureMemoryBankBaseline(input) {
9
+ const projectRoot = path.resolve(input.projectRoot);
10
+ const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
11
+ if (paths.length === 0) {
12
+ throw new AppError("validation", "Memory Bank baseline requires at least one exact target path", 2);
13
+ }
14
+ return {
15
+ schema_id: "dd-flow/memory-bank-baseline@1",
16
+ project_root: projectRoot,
17
+ paths: Object.fromEntries(paths.map((relativePath) => [relativePath, snapshotMemoryBankPath(projectRoot, relativePath)]))
18
+ };
19
+ }
20
+ export function validateMemoryBankDelta(input) {
21
+ const projectRoot = path.resolve(input.projectRoot);
22
+ if (input.baseline.schema_id !== "dd-flow/memory-bank-baseline@1" || path.resolve(input.baseline.project_root) !== projectRoot) {
23
+ throw new AppError("validation", "Memory Bank baseline does not belong to this project root", 2, {
24
+ baseline_root: input.baseline.project_root,
25
+ project_root: projectRoot
26
+ });
27
+ }
28
+ const paths = normalizeMemoryBankPaths(projectRoot, Object.keys(input.baseline.paths));
29
+ if (paths.length === 0) {
30
+ throw new AppError("validation", "Memory Bank baseline has no exact target paths", 2);
31
+ }
32
+ const after = Object.fromEntries(paths.map((relativePath) => [relativePath, snapshotMemoryBankPath(projectRoot, relativePath)]));
33
+ const snapshots = paths.map((relativePath) => {
34
+ const before = input.baseline.paths[relativePath];
35
+ const current = after[relativePath];
36
+ if (!before || !current) {
37
+ throw new AppError("validation", `Memory Bank baseline snapshot is incomplete: ${relativePath}`, 2);
38
+ }
39
+ return { relativePath, before, after: current };
40
+ });
41
+ const changedSnapshots = snapshots.filter(({ before, after: current }) => !sameMemoryBankSnapshot(before, current));
42
+ const changed = changedSnapshots.map(({ relativePath }) => relativePath);
43
+ const added = changedSnapshots.filter(({ before, after: current }) => !before.exists && current.exists).map(({ relativePath }) => relativePath);
44
+ const removed = changedSnapshots.filter(({ before, after: current }) => before.exists && !current.exists).map(({ relativePath }) => relativePath);
45
+ const findings = [];
46
+ for (const { relativePath, after: snapshot } of changedSnapshots) {
47
+ if (!snapshot.exists) {
48
+ findings.push({ path: relativePath, severity: "error", code: "deleted_target", message: "Tracked Memory Bank target was deleted" });
49
+ continue;
50
+ }
51
+ findings.push(...validateChangedMemoryFile(projectRoot, relativePath));
52
+ }
53
+ return {
54
+ schema_id: "dd-flow/memory-bank-delta-validation@1",
55
+ ok: findings.length === 0,
56
+ project_root: projectRoot,
57
+ checked_paths: paths,
58
+ baseline: input.baseline.paths,
59
+ delta: { changed, added, removed },
60
+ findings
61
+ };
62
+ }
7
63
  const schemaNamePattern = /^[a-z0-9][a-z0-9-]*$/;
8
64
  const mandatoryMbUpgradeReviewAspectIds = [
9
65
  "01-goal-and-delivery",
@@ -37,7 +93,7 @@ export function validateSchema(options) {
37
93
  }
38
94
  const filePath = path.resolve(options.file);
39
95
  const data = readJson(filePath, "input file");
40
- const schemaResolution = resolveSchema(options, data);
96
+ const schemaResolution = resolveSchema(options);
41
97
  const schema = readJson(schemaResolution.path, "schema");
42
98
  const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
43
99
  const validate = ajv.compile(schema);
@@ -59,12 +115,8 @@ export function validateSchema(options) {
59
115
  errors: []
60
116
  };
61
117
  }
62
- function resolveSchema(options, data) {
63
- const dataRecord = asRecord(data);
64
- const dataSchemaId = dataRecord ? stringValue(dataRecord, "schema_id") : undefined;
65
- const fileNames = options.schemaName === "flow-run-index" && dataSchemaId === "dd-flow/flow-run-index@3"
66
- ? ["flow-run-index-v3.schema.json", "flow-run-index.schema.json"]
67
- : [`${options.schemaName}.schema.json`];
118
+ function resolveSchema(options) {
119
+ const fileNames = [`${options.schemaName}.schema.json`];
68
120
  const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
69
121
  const roots = [
70
122
  ...(options.schemaDir ? [{ directory: path.resolve(options.schemaDir), source: "schema_dir" }] : []),
@@ -262,6 +314,115 @@ function validateMbSdlcReviewReport(root) {
262
314
  });
263
315
  return errors;
264
316
  }
317
+ function normalizeMemoryBankPaths(projectRoot, inputs) {
318
+ return [...new Set(inputs.map((input) => {
319
+ const absolute = resolveMemoryBankPath(projectRoot, input);
320
+ const relative = path.relative(projectRoot, absolute);
321
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
322
+ throw new AppError("validation", "Memory Bank target must remain inside project root", 2, { target: input });
323
+ }
324
+ return relative;
325
+ }))].sort();
326
+ }
327
+ function resolveMemoryBankPath(projectRoot, input) {
328
+ const absolute = path.isAbsolute(input) ? path.resolve(input) : path.resolve(projectRoot, input);
329
+ const relative = path.relative(projectRoot, absolute);
330
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
331
+ throw new AppError("validation", "Memory Bank target must remain inside project root", 2, { target: input });
332
+ }
333
+ const existing = nearestExistingPath(absolute);
334
+ if (existing) {
335
+ const realRoot = fs.realpathSync(projectRoot);
336
+ const realExisting = fs.realpathSync(existing);
337
+ const realResolved = path.resolve(realExisting, path.relative(existing, absolute));
338
+ const realRelative = path.relative(realRoot, realResolved);
339
+ if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
340
+ throw new AppError("validation", "Memory Bank target escapes project root through a symlink", 2, { target: input });
341
+ }
342
+ }
343
+ return absolute;
344
+ }
345
+ function nearestExistingPath(target) {
346
+ let current = target;
347
+ while (!fs.existsSync(current)) {
348
+ const parent = path.dirname(current);
349
+ if (parent === current)
350
+ return undefined;
351
+ current = parent;
352
+ }
353
+ return current;
354
+ }
355
+ function snapshotMemoryBankPath(projectRoot, relativePath) {
356
+ const absolute = resolveMemoryBankPath(projectRoot, relativePath);
357
+ if (!fs.existsSync(absolute))
358
+ return { exists: false, bytes: null, sha256: null };
359
+ let stat;
360
+ let content;
361
+ try {
362
+ stat = fs.statSync(absolute);
363
+ if (!stat.isFile())
364
+ throw new Error("target is not a file");
365
+ content = fs.readFileSync(absolute);
366
+ }
367
+ catch (error) {
368
+ throw new AppError("validation", `Cannot snapshot Memory Bank target: ${relativePath}`, 2, { cause: String(error) });
369
+ }
370
+ return {
371
+ exists: true,
372
+ bytes: content.byteLength,
373
+ sha256: crypto.createHash("sha256").update(content).digest("hex")
374
+ };
375
+ }
376
+ function sameMemoryBankSnapshot(before, after) {
377
+ return Boolean(before)
378
+ && before?.exists === after.exists
379
+ && before?.bytes === after.bytes
380
+ && before?.sha256 === after.sha256;
381
+ }
382
+ function validateChangedMemoryFile(projectRoot, relativePath) {
383
+ const absolute = resolveMemoryBankPath(projectRoot, relativePath);
384
+ let content;
385
+ try {
386
+ content = fs.readFileSync(absolute, "utf8");
387
+ }
388
+ catch (error) {
389
+ return [{ path: relativePath, severity: "error", code: "unreadable_target", message: String(error) }];
390
+ }
391
+ const findings = [];
392
+ if (path.extname(relativePath).toLowerCase() === ".json") {
393
+ try {
394
+ JSON.parse(content);
395
+ }
396
+ catch (error) {
397
+ findings.push({ path: relativePath, severity: "error", code: "invalid_json", message: String(error) });
398
+ }
399
+ }
400
+ const markdownLinkPattern = /\]\((<[^>]+>|[^)\s]+)(?:\s+["'][^)]*["'])?\)/g;
401
+ for (const match of content.matchAll(markdownLinkPattern)) {
402
+ const capturedTarget = match[1];
403
+ if (!capturedTarget)
404
+ continue;
405
+ const rawTarget = capturedTarget.replace(/^<|>$/g, "");
406
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(rawTarget))
407
+ continue;
408
+ const linkTarget = rawTarget.split(/[?#]/, 1)[0];
409
+ if (!linkTarget)
410
+ continue;
411
+ let resolved;
412
+ try {
413
+ const linkedAbsolute = path.resolve(path.dirname(path.join(projectRoot, relativePath)), linkTarget);
414
+ resolved = resolveMemoryBankPath(projectRoot, path.relative(projectRoot, linkedAbsolute));
415
+ }
416
+ catch (error) {
417
+ findings.push({ path: relativePath, severity: "error", code: "path_escape", message: String(error) });
418
+ continue;
419
+ }
420
+ if (!fs.existsSync(resolved)) {
421
+ findings.push({ path: relativePath, severity: "error", code: "missing_link", message: `Linked path does not exist: ${linkTarget}` });
422
+ }
423
+ }
424
+ return findings;
425
+ }
265
426
  function asRecord(value) {
266
427
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
267
428
  }
@@ -9,6 +9,58 @@ import { cancelLaneWaitersForWorker } from "./lanes.js";
9
9
  import { checkpointSessionUsage } from "./usage.js";
10
10
  import { refreshRunSessionProjection } from "./run-projection.js";
11
11
  import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ export function recordFlowSessionObservation(context, input) {
13
+ const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
14
+ if (duplicate)
15
+ return { changed: false, segment_id: duplicate.id };
16
+ const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
17
+ WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
18
+ const now = context.now();
19
+ const runId = input.runId ?? null;
20
+ const protocolId = input.protocolId ?? null;
21
+ if (open && open.run_id === runId && open.protocol_id === protocolId) {
22
+ return { changed: false, segment_id: open.id };
23
+ }
24
+ if (open)
25
+ context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE id = ? AND ended_at IS NULL", [now, open.id]);
26
+ const result = context.db.run(`INSERT INTO flow_session_segments
27
+ (project_id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name, event_key)
28
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
29
+ return { changed: true, segment_id: Number(result.lastInsertRowid) };
30
+ }
31
+ export function bindObservedFlowSession(context, project, payload, sessionId) {
32
+ if (resolveProjectRoot(payload.project_root) !== project.root) {
33
+ throw new AppError("project_mismatch", "Observed session payload project_root does not match hook cwd project", 1, {
34
+ expected: project.root,
35
+ actual: payload.project_root
36
+ });
37
+ }
38
+ const session = upsertFlowSession(context, project, payload, sessionId);
39
+ if (session.session_kind !== "orchestrator") {
40
+ const now = context.now();
41
+ for (const unit of payload.coverage_units ?? []) {
42
+ if (!unit.job_id)
43
+ continue;
44
+ context.db.run(`UPDATE flow_jobs SET status = 'running', worker_session_id = ?, attempts = attempts + 1, started_at = COALESCE(started_at, ?), updated_at = ?
45
+ WHERE project_id = ? AND run_id = ? AND job_id = ? AND status IN ('pending', 'failed')`, [session.session_id, now, now, project.id, session.run_id ?? "", unit.job_id]);
46
+ }
47
+ }
48
+ if (session.run_id)
49
+ refreshRunSessionProjection(context, project.id, session.run_id);
50
+ return session;
51
+ }
52
+ export function registerFlowJob(context, input) {
53
+ const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
54
+ const now = context.now();
55
+ context.db.run(`INSERT INTO flow_jobs (job_id, project_id, run_id, protocol_id, plan_item_id, group_id, status, worker_session_id, attempts, last_error, registered_at, updated_at, started_at, finished_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, 0, NULL, ?, ?, NULL, NULL)
57
+ ON CONFLICT(project_id, run_id, plan_item_id) DO UPDATE SET protocol_id = excluded.protocol_id, group_id = excluded.group_id, updated_at = excluded.updated_at`, [jobId, input.projectId, input.runId, input.protocolId ?? null, input.planItemId, input.groupId ?? null, now, now]);
58
+ refreshRunSessionProjection(context, input.projectId, input.runId);
59
+ return context.db.get("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? AND plan_item_id = ?", [input.projectId, input.runId, input.planItemId]) ?? { job_id: jobId };
60
+ }
61
+ export function flowJobsForRun(context, projectId, runId) {
62
+ return context.db.all("SELECT * FROM flow_jobs WHERE project_id = ? AND run_id = ? ORDER BY plan_item_id", [projectId, runId]);
63
+ }
12
64
  export function registerFlowSession(context, input) {
13
65
  const payload = decodeFlowSessionPayload(input);
14
66
  const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
@@ -36,12 +88,78 @@ export function registerFlowSession(context, input) {
36
88
  }
37
89
  export function getFlowSessionStatus(context, input) {
38
90
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
91
+ const sessions = flowSessionsForProject(context, project.id, {
92
+ sessionId: input.sessionId,
93
+ workerId: input.workerId
94
+ });
39
95
  return {
40
96
  ok: true,
41
- sessions: flowSessionsForProject(context, project.id, {
42
- sessionId: input.sessionId,
43
- workerId: input.workerId
44
- })
97
+ sessions,
98
+ coverage: reconcileSessionCoverage(sessions)
99
+ };
100
+ }
101
+ export function reconcileSessionCoverage(sessions) {
102
+ const sessionCounts = countValues(sessions.map((session) => session.session_id));
103
+ const duplicateSessionIds = [...sessionCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
104
+ const invalidSessionIds = [];
105
+ const parsed = sessions.map((session) => {
106
+ try {
107
+ return { session, units: normalizeCoverageUnits(JSON.parse(session.coverage_units_json || "[]")) };
108
+ }
109
+ catch {
110
+ invalidSessionIds.push(session.session_id);
111
+ return { session, units: [] };
112
+ }
113
+ });
114
+ const orchestrators = parsed.filter(({ session }) => session.session_kind === "orchestrator");
115
+ const expectedCounts = countValues(orchestrators.flatMap(({ units }) => units.map((unit) => unit.unit_id)));
116
+ const expectedUnitIds = [...expectedCounts.keys()].sort();
117
+ const duplicateExpectedUnitIds = [...expectedCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id).sort();
118
+ const observedOwners = new Map();
119
+ for (const { session, units } of parsed) {
120
+ if (session.session_kind === "orchestrator")
121
+ continue;
122
+ for (const unit of units) {
123
+ const owners = observedOwners.get(unit.unit_id) ?? [];
124
+ owners.push(session.session_id);
125
+ observedOwners.set(unit.unit_id, owners);
126
+ }
127
+ }
128
+ const observedUnitIds = [...observedOwners.keys()].sort();
129
+ const missingUnitIds = expectedUnitIds.filter((unitId) => !observedOwners.has(unitId));
130
+ const duplicateUnitIds = [...observedOwners.entries()].filter(([, owners]) => new Set(owners).size > 1).map(([id]) => id).sort();
131
+ const diagnostics = [];
132
+ if (orchestrators.length === 0)
133
+ diagnostics.push("orchestrator_session_missing");
134
+ if (orchestrators.length > 1)
135
+ diagnostics.push("multiple_orchestrator_sessions");
136
+ if (expectedUnitIds.length === 0)
137
+ diagnostics.push("expected_worker_units_missing");
138
+ if (missingUnitIds.length > 0)
139
+ diagnostics.push("expected_worker_units_unobserved");
140
+ if (duplicateUnitIds.length > 0 || duplicateExpectedUnitIds.length > 0)
141
+ diagnostics.push("duplicate_coverage_binding");
142
+ if (duplicateSessionIds.length > 0)
143
+ diagnostics.push("duplicate_session_binding");
144
+ if (invalidSessionIds.length > 0)
145
+ diagnostics.push("invalid_coverage_units");
146
+ const unavailable = orchestrators.length === 0 || expectedUnitIds.length === 0 || invalidSessionIds.length > 0;
147
+ const partial = orchestrators.length !== 1
148
+ || missingUnitIds.length > 0
149
+ || duplicateUnitIds.length > 0
150
+ || duplicateExpectedUnitIds.length > 0
151
+ || duplicateSessionIds.length > 0;
152
+ return {
153
+ status: unavailable ? "unavailable" : partial ? "partial" : "complete",
154
+ expected_unit_ids: expectedUnitIds,
155
+ observed_unit_ids: observedUnitIds,
156
+ missing_unit_ids: missingUnitIds,
157
+ duplicate_unit_ids: duplicateUnitIds,
158
+ duplicate_expected_unit_ids: duplicateExpectedUnitIds,
159
+ duplicate_session_ids: duplicateSessionIds,
160
+ orchestrator_session_ids: orchestrators.map(({ session }) => session.session_id).sort(),
161
+ invalid_session_ids: [...new Set(invalidSessionIds)].sort(),
162
+ diagnostics
45
163
  };
46
164
  }
47
165
  export function stopFlowSession(context, input) {
@@ -126,63 +244,6 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
126
244
  refreshRunSessionProjection(context, projectId, session.run_id);
127
245
  return nextCount;
128
246
  }
129
- export function recordPendingFlowSessionBinding(context, project, input) {
130
- const payload = flowSessionPayloadFromRegisterCommand(input.command);
131
- if (!payload) {
132
- return { recorded: false };
133
- }
134
- const payloadRoot = resolveProjectRoot(payload.project_root);
135
- if (payloadRoot !== project.root) {
136
- throw new AppError("project_mismatch", "Session register payload project_root does not match hook project", 1, {
137
- expected: project.root,
138
- actual: payloadRoot
139
- });
140
- }
141
- const now = context.now();
142
- context.db.run(`INSERT INTO pending_flow_session_bindings
143
- (session_id, project_id, payload_json, cwd, transcript_path, turn_id, status, created_at, updated_at)
144
- VALUES (?, ?, ?, ?, ?, ?, 'observed', ?, ?)
145
- ON CONFLICT(session_id, project_id) DO UPDATE SET
146
- payload_json = excluded.payload_json,
147
- cwd = COALESCE(excluded.cwd, cwd),
148
- transcript_path = COALESCE(excluded.transcript_path, transcript_path),
149
- turn_id = excluded.turn_id,
150
- status = 'observed',
151
- updated_at = excluded.updated_at`, [
152
- input.sessionId,
153
- project.id,
154
- JSON.stringify(sanitizeSessionPayload(payload)),
155
- input.cwd ?? payload.cwd ?? null,
156
- input.transcriptPath ?? payload.transcript_path ?? null,
157
- input.turnId ?? null,
158
- now,
159
- now
160
- ]);
161
- return { recorded: true, payload };
162
- }
163
- export function confirmPendingFlowSessionBinding(context, project, input) {
164
- const pending = context.db.get("SELECT * FROM pending_flow_session_bindings WHERE project_id = ? AND session_id = ? AND status = 'observed'", [project.id, input.sessionId]);
165
- if (!pending) {
166
- return undefined;
167
- }
168
- const payload = normalizeFlowSessionPayload(JSON.parse(pending.payload_json));
169
- const session = upsertFlowSession(context, project, {
170
- ...payload,
171
- cwd: pending.cwd ?? payload.cwd ?? null,
172
- transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
173
- }, input.sessionId);
174
- if (session.run_id)
175
- refreshRunSessionProjection(context, project.id, session.run_id);
176
- context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
177
- WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
178
- appendAudit(context, {
179
- projectId: project.id,
180
- eventType: "flow_session.bound",
181
- payload: { project_id: project.id, session_id: input.sessionId, flow_kind: session.flow_kind },
182
- ...(session.protocol_id ? { protocolId: session.protocol_id } : {})
183
- });
184
- return session;
185
- }
186
247
  export function flowSessionPayloadFromRegisterCommand(command) {
187
248
  if (!/\bdd-flow\s+session\s+register\b/.test(command)) {
188
249
  return undefined;
@@ -320,6 +381,10 @@ function markSessionStopped(context, project, session, reason) {
320
381
  context.db.run(`UPDATE flow_sessions
321
382
  SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
322
383
  WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
384
+ if (session.run_id && session.session_kind !== "orchestrator") {
385
+ context.db.run(`UPDATE flow_jobs SET status = 'pending', worker_session_id = NULL, last_error = ?, updated_at = ?
386
+ WHERE project_id = ? AND run_id = ? AND worker_session_id = ? AND status = 'running'`, [reason, now, project.id, session.run_id, session.session_id]);
387
+ }
323
388
  if (session.run_id)
324
389
  appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_stopped", session_id: session.session_id });
325
390
  if (session.run_id)
@@ -374,13 +439,6 @@ function optionFromCommand(command, key) {
374
439
  const match = command.match(pattern);
375
440
  return match?.[1] ?? match?.[2] ?? match?.[3];
376
441
  }
377
- function sanitizeSessionPayload(payload) {
378
- return {
379
- ...payload,
380
- next_action: redactString(payload.next_action ?? null),
381
- metadata: sanitizeValue(payload.metadata ?? {})
382
- };
383
- }
384
442
  function sanitizeValue(value) {
385
443
  if (Array.isArray(value)) {
386
444
  return value.map(sanitizeValue);
@@ -442,6 +500,12 @@ function normalizeCoverageUnits(value) {
442
500
  return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
443
501
  });
444
502
  }
503
+ function countValues(values) {
504
+ const counts = new Map();
505
+ for (const value of values)
506
+ counts.set(value, (counts.get(value) ?? 0) + 1);
507
+ return counts;
508
+ }
445
509
  function requiredStringField(payload, key) {
446
510
  const value = stringField(payload, key, true);
447
511
  if (!value) {