@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.
- package/CHANGELOG.md +27 -4
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +14 -3
- package/dist/cli/run-cli.js +32 -16
- package/dist/domain/flow-contract.js +81 -2
- package/dist/domain/validation.js +56 -28
- package/dist/protocol/local-files.js +1 -16
- package/dist/schemas/code-stage-report.schema.json +2 -2
- package/dist/schemas/flow-contract.schema.json +150 -94
- package/dist/schemas/flow-run.schema.json +129 -23
- package/dist/schemas/mb-upgrade-review-data.schema.json +2 -2
- package/dist/schemas/memorybank-permissions-preflight.schema.json +13 -73
- package/dist/schemas/merge-stage-report.schema.json +2 -2
- package/dist/schemas/plan-stage-report.schema.json +38 -335
- package/dist/schemas/project-flow-pack-manifest.schema.json +4 -4
- package/dist/schemas/protocol-plan.schema.json +197 -0
- package/dist/schemas/release-impact.schema.json +9 -5
- package/dist/schemas/session-usage.schema.json +16 -0
- package/dist/schemas/stage-finish-input.schema.json +20 -0
- package/dist/schemas/stage-prompt.schema.json +31 -0
- package/dist/schemas/stage-report.schema.json +20 -0
- package/dist/schemas/stage-start-response.schema.json +29 -0
- package/dist/schemas/timeline-event.schema.json +29 -0
- package/dist/schemas/worktrunk-workspace.schema.json +19 -0
- package/dist/services/branch-context.js +9 -4
- package/dist/services/dashboard.js +51 -26
- package/dist/services/engines.js +84 -18
- package/dist/services/hooks.js +80 -246
- package/dist/services/memory-permissions.js +77 -69
- package/dist/services/plan-runtime.js +124 -0
- package/dist/services/plans.js +22 -84
- package/dist/services/projects.js +2 -1
- package/dist/services/prompts.js +26 -21
- package/dist/services/protocols.js +29 -25
- package/dist/services/run-projection.js +77 -11
- package/dist/services/runs.js +95 -61
- package/dist/services/schema-validation.js +168 -7
- package/dist/services/sessions.js +132 -68
- package/dist/services/stage-lifecycle.js +572 -0
- package/dist/services/tooling.js +285 -0
- package/dist/services/usage.js +183 -30
- package/dist/services/version-status.js +1 -1
- package/dist/services/worktrees.js +88 -39
- package/dist/storage/database.js +72 -30
- package/dist/storage/paths.js +0 -9
- package/package.json +14 -13
- package/tools/worktrunk-manifest.json +34 -0
- package/dist/schemas/flow-run-index-v3.schema.json +0 -203
- 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
|
|
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
|
|
63
|
-
const
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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) {
|