@deksden-com/dd-flow-cli 0.4.2 → 0.6.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 +33 -4
- package/README.md +2 -2
- package/dist/build-info.json +6 -6
- package/dist/cli/help.js +16 -4
- package/dist/cli/run-cli.js +42 -17
- package/dist/domain/flow-contract.js +82 -3
- package/dist/domain/session-coverage.js +88 -0
- 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 +152 -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 +35 -0
- package/dist/schemas/stage-report.schema.json +20 -0
- package/dist/schemas/stage-start-response.schema.json +30 -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/cleanup.js +77 -0
- package/dist/services/dashboard.js +51 -26
- package/dist/services/engines.js +84 -18
- package/dist/services/hooks.js +101 -250
- package/dist/services/memory-permissions.js +77 -69
- package/dist/services/migrations.js +1 -1
- 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 +93 -13
- package/dist/services/runs.js +128 -61
- package/dist/services/schema-validation.js +168 -7
- package/dist/services/sessions.js +97 -73
- package/dist/services/stage-lifecycle.js +737 -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
package/dist/services/runs.js
CHANGED
|
@@ -5,17 +5,15 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
6
6
|
import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
|
|
7
7
|
import { AppError } from "../shared/errors.js";
|
|
8
|
-
import { ensureDir, projectRunHome,
|
|
8
|
+
import { ensureDir, projectRunHome, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
11
11
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
12
12
|
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
13
13
|
import { checkpointRunUsage, usageForRun } from "./usage.js";
|
|
14
14
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
15
|
-
const runSchemaId = "dd-flow/flow-run
|
|
16
|
-
const
|
|
17
|
-
const oldestRunSchemaId = "dd-flow/flow-run-index@1";
|
|
18
|
-
const runtimeSchemaId = "dd-flow/flow-run@1";
|
|
15
|
+
const runSchemaId = "dd-flow/flow-run@2";
|
|
16
|
+
const runtimeSchemaId = "dd-flow/flow-run@2";
|
|
19
17
|
const runIdType = "RUN";
|
|
20
18
|
const allowedRunFlowKinds = [
|
|
21
19
|
"mb_sdlc",
|
|
@@ -51,7 +49,6 @@ export function startFlowRun(context, input) {
|
|
|
51
49
|
now
|
|
52
50
|
});
|
|
53
51
|
const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
|
|
54
|
-
const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
|
|
55
52
|
const runDirRelative = path.posix.join("runs", runId);
|
|
56
53
|
const runtimePath = projectRunJsonPath(context.ddFlowHome, project.id, runId);
|
|
57
54
|
const index = {
|
|
@@ -60,16 +57,19 @@ export function startFlowRun(context, input) {
|
|
|
60
57
|
short_id: shortId,
|
|
61
58
|
flow_kind: flowKind,
|
|
62
59
|
subject: {
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
id: requiredPlain(input.subjectId, "subject-id"),
|
|
61
|
+
title: requiredPlain(input.subjectId, "subject-id")
|
|
65
62
|
},
|
|
66
63
|
project: {
|
|
67
64
|
id: project.id,
|
|
68
|
-
|
|
65
|
+
title: path.basename(project.root)
|
|
69
66
|
},
|
|
70
67
|
workspace: {
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
project_root: project.root,
|
|
69
|
+
workspace_path: workspaceRoot,
|
|
70
|
+
branch: gitFacts(workspaceRoot).branch ?? "unknown",
|
|
71
|
+
base: gitFacts(workspaceRoot).head ?? "unknown",
|
|
72
|
+
manager: "official-worktrunk"
|
|
73
73
|
},
|
|
74
74
|
run_home: {
|
|
75
75
|
storage: "dd_flow_home",
|
|
@@ -86,7 +86,7 @@ export function startFlowRun(context, input) {
|
|
|
86
86
|
},
|
|
87
87
|
stage_runs: [],
|
|
88
88
|
sessions: [],
|
|
89
|
-
artifacts:
|
|
89
|
+
artifacts: { stage_prompt: path.join(runHome, "stage-prompt.md"), stage_report_json: path.join(runHome, "stage-report.json"), stage_report_md: path.join(runHome, "stage-report.md"), stage_report_html: path.join(runHome, "stage-report.html"), summary: path.join(runHome, "summary.md") },
|
|
90
90
|
status: "running",
|
|
91
91
|
verdict: "pending",
|
|
92
92
|
next_action: input.nextAction ?? null,
|
|
@@ -98,12 +98,16 @@ export function startFlowRun(context, input) {
|
|
|
98
98
|
snapshot_revision: flowFlags.snapshot_revision,
|
|
99
99
|
snapshot_checksum: flowFlags.snapshot_checksum,
|
|
100
100
|
reconciliation_status: "reconciled",
|
|
101
|
-
flow_flags: flowFlags
|
|
101
|
+
flow_flags: flowFlags,
|
|
102
|
+
current_stage: null,
|
|
103
|
+
attempts: [],
|
|
104
|
+
session_coverage: { status: "unavailable", expected: [], observed: [], missing: [] },
|
|
105
|
+
usage_coverage: { status: "unavailable", expected: [], observed: [], missing: [] },
|
|
106
|
+
timeline_path: "timeline.jsonl"
|
|
102
107
|
};
|
|
103
108
|
ensureDir(path.dirname(runtimePath));
|
|
104
109
|
ensureDir(runHome);
|
|
105
110
|
writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
|
|
106
|
-
writeJsonFile(runIndexAbsolute, index);
|
|
107
111
|
context.db.run(`INSERT INTO flow_runs
|
|
108
112
|
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
109
113
|
status, verdict, next_action, runtime_path, run_dir, run_index_path, run_home_path, layout_version, artifact_root_kind,
|
|
@@ -116,14 +120,14 @@ export function startFlowRun(context, input) {
|
|
|
116
120
|
project.root,
|
|
117
121
|
workspaceRoot,
|
|
118
122
|
flowKind,
|
|
119
|
-
|
|
123
|
+
requiredPlain(input.subjectType, "subject-type"),
|
|
120
124
|
index.subject.id,
|
|
121
125
|
index.status,
|
|
122
126
|
index.verdict,
|
|
123
127
|
index.next_action,
|
|
124
128
|
runtimePath,
|
|
125
129
|
runHome,
|
|
126
|
-
|
|
130
|
+
runtimePath,
|
|
127
131
|
runHome,
|
|
128
132
|
"home_run_v2",
|
|
129
133
|
"dd_flow_home",
|
|
@@ -152,14 +156,14 @@ export function getFlowRunStatus(context, input) {
|
|
|
152
156
|
const run = resolveRun(context, project.id, input.runId);
|
|
153
157
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
154
158
|
const refreshedRun = requireRunById(context, project.id, run.id);
|
|
155
|
-
const index =
|
|
159
|
+
const index = authoritativeIndex(refreshedRun);
|
|
156
160
|
const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
|
|
157
161
|
return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
|
|
158
162
|
}
|
|
159
163
|
export function getFlowRunFlagsStatus(context, input) {
|
|
160
164
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
161
165
|
const run = resolveRun(context, project.id, input.runId);
|
|
162
|
-
const index =
|
|
166
|
+
const index = authoritativeIndex(run);
|
|
163
167
|
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
164
168
|
const flags = runtime?.flow_flags ?? index.flow_flags;
|
|
165
169
|
if (!flags) {
|
|
@@ -199,7 +203,7 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
199
203
|
// Read the authority only after the write lock: two callers with the same
|
|
200
204
|
// expected revision must not both pass the compare-and-set check.
|
|
201
205
|
const lockedRun = requireRunById(context, project.id, run.id);
|
|
202
|
-
const index =
|
|
206
|
+
const index = authoritativeIndex(lockedRun);
|
|
203
207
|
const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
|
|
204
208
|
const current = runtime?.flow_flags ?? index.flow_flags;
|
|
205
209
|
if (!current) {
|
|
@@ -268,7 +272,7 @@ export function reviseFlowRunFlags(context, input) {
|
|
|
268
272
|
flow_flags: next,
|
|
269
273
|
...(reason ? { reason } : {})
|
|
270
274
|
};
|
|
271
|
-
|
|
275
|
+
persistRunState(context, project, run, index, {
|
|
272
276
|
flagRevision: { revision: next.snapshot_revision, checksum: next.snapshot_checksum, at: now, idempotency_key: idempotencyKey }
|
|
273
277
|
});
|
|
274
278
|
context.db.run(`INSERT INTO flow_run_flag_mutations
|
|
@@ -310,7 +314,7 @@ export function listFlowRuns(context, input) {
|
|
|
310
314
|
export function attachFlowRunStage(context, input) {
|
|
311
315
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
312
316
|
const run = resolveRun(context, project.id, input.runId);
|
|
313
|
-
const index =
|
|
317
|
+
const index = authoritativeIndex(run);
|
|
314
318
|
const now = context.now();
|
|
315
319
|
const stage = requiredPlain(input.stage, "stage");
|
|
316
320
|
const dir = requiredStageDir(input.dir);
|
|
@@ -325,13 +329,20 @@ export function attachFlowRunStage(context, input) {
|
|
|
325
329
|
dir,
|
|
326
330
|
status,
|
|
327
331
|
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
328
|
-
updated_at: now
|
|
329
|
-
...(status === "running" && !existing?.started_at ? { started_at: now } : {}),
|
|
330
|
-
...(status === "running" ? { attempt: nextAttempt(existing) } : {})
|
|
332
|
+
updated_at: now
|
|
331
333
|
};
|
|
334
|
+
if (status === "running") {
|
|
335
|
+
stageRun.started_at = now;
|
|
336
|
+
delete stageRun.completed_at;
|
|
337
|
+
stageRun.duration_ms = null;
|
|
338
|
+
stageRun.git = gitFacts(run.workspace_root);
|
|
339
|
+
stageRun.attempt = nextAttempt(existing);
|
|
340
|
+
}
|
|
332
341
|
upsertStage(index, stageRun);
|
|
342
|
+
index.current_stage = stage;
|
|
343
|
+
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
333
344
|
index.updated_at = now;
|
|
334
|
-
|
|
345
|
+
persistRunState(context, project, run, index);
|
|
335
346
|
appendAudit(context, {
|
|
336
347
|
projectId: project.id,
|
|
337
348
|
eventType: "flow_run.stage_attached",
|
|
@@ -354,7 +365,7 @@ export function attachFlowRunStage(context, input) {
|
|
|
354
365
|
export function completeFlowRunStage(context, input) {
|
|
355
366
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
356
367
|
const run = resolveRun(context, project.id, input.runId);
|
|
357
|
-
const index =
|
|
368
|
+
const index = authoritativeIndex(run);
|
|
358
369
|
const now = context.now();
|
|
359
370
|
const stage = requiredPlain(input.stage, "stage");
|
|
360
371
|
const status = parseStageStatus(input.status);
|
|
@@ -371,11 +382,20 @@ export function completeFlowRunStage(context, input) {
|
|
|
371
382
|
...(input.report ? { report: input.report } : {}),
|
|
372
383
|
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
373
384
|
updated_at: now,
|
|
374
|
-
...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
|
|
385
|
+
...(status === "done" || status === "blocked" || status === "failed" || status === "skipped"
|
|
386
|
+
? { completed_at: now, duration_ms: durationMs(existing.started_at ?? null, now), git: gitFacts(run.workspace_root) }
|
|
387
|
+
: {})
|
|
375
388
|
};
|
|
376
389
|
upsertStage(index, stageRun);
|
|
390
|
+
index.current_stage = stage;
|
|
391
|
+
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
377
392
|
index.updated_at = now;
|
|
378
|
-
|
|
393
|
+
index.execution = {
|
|
394
|
+
project_root: index.execution?.project_root ?? run.project_root,
|
|
395
|
+
workspace_root: index.execution?.workspace_root ?? run.workspace_root,
|
|
396
|
+
git: gitFacts(run.workspace_root)
|
|
397
|
+
};
|
|
398
|
+
persistRunState(context, project, run, index);
|
|
379
399
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
380
400
|
appendAudit(context, {
|
|
381
401
|
projectId: project.id,
|
|
@@ -390,7 +410,7 @@ export function completeFlowRunStage(context, input) {
|
|
|
390
410
|
export function completeFlowRun(context, input) {
|
|
391
411
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
392
412
|
const run = resolveRun(context, project.id, input.runId);
|
|
393
|
-
const index =
|
|
413
|
+
const index = authoritativeIndex(run);
|
|
394
414
|
const now = context.now();
|
|
395
415
|
const status = parseRunStatus(input.status);
|
|
396
416
|
index.status = status;
|
|
@@ -399,8 +419,16 @@ export function completeFlowRun(context, input) {
|
|
|
399
419
|
index.updated_at = now;
|
|
400
420
|
if (["done", "blocked", "cancelled", "failed"].includes(status)) {
|
|
401
421
|
index.completed_at = now;
|
|
422
|
+
index.finished_at = now;
|
|
423
|
+
index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
|
|
424
|
+
index.execution = {
|
|
425
|
+
project_root: index.execution?.project_root ?? run.project_root,
|
|
426
|
+
workspace_root: index.execution?.workspace_root ?? run.workspace_root,
|
|
427
|
+
git: gitFacts(run.workspace_root)
|
|
428
|
+
};
|
|
402
429
|
}
|
|
403
|
-
|
|
430
|
+
persistRunState(context, project, run, index);
|
|
431
|
+
closeRunOrchestrators(context, project.id, run, status, now);
|
|
404
432
|
refreshRunSessionProjection(context, project.id, run.id);
|
|
405
433
|
appendAudit(context, {
|
|
406
434
|
projectId: project.id,
|
|
@@ -412,11 +440,35 @@ export function completeFlowRun(context, input) {
|
|
|
412
440
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
413
441
|
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
414
442
|
}
|
|
443
|
+
function closeRunOrchestrators(context, projectId, run, status, now) {
|
|
444
|
+
if (!["done", "blocked", "cancelled", "failed"].includes(status))
|
|
445
|
+
return;
|
|
446
|
+
const active = context.db.all(`SELECT session_id FROM flow_sessions
|
|
447
|
+
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
448
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [projectId, run.id]);
|
|
449
|
+
if (active.length === 0)
|
|
450
|
+
return;
|
|
451
|
+
const reason = `run_${status}`;
|
|
452
|
+
context.db.run(`UPDATE flow_sessions SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
453
|
+
WHERE project_id = ? AND run_id = ? AND session_kind = 'orchestrator'
|
|
454
|
+
AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [reason, now, now, projectId, run.id]);
|
|
455
|
+
context.db.run(`UPDATE flow_session_segments SET ended_at = ?
|
|
456
|
+
WHERE project_id = ? AND run_id = ? AND session_id IN (${active.map(() => "?").join(", ")}) AND ended_at IS NULL`, [now, projectId, run.id, ...active.map((session) => session.session_id)]);
|
|
457
|
+
for (const session of active) {
|
|
458
|
+
appendRunTimeline(runArtifactRoot(run), {
|
|
459
|
+
at: now,
|
|
460
|
+
type: "session_stopped",
|
|
461
|
+
run_id: run.id,
|
|
462
|
+
session_id: session.session_id,
|
|
463
|
+
reason
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
415
467
|
const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
|
|
416
468
|
export function getFlowRunTimeline(context, input) {
|
|
417
469
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
418
470
|
const run = resolveRun(context, project.id, input.runId);
|
|
419
|
-
const index =
|
|
471
|
+
const index = authoritativeIndex(run);
|
|
420
472
|
const file = path.join(runArtifactRoot(run), "timeline.jsonl");
|
|
421
473
|
const events = fs.existsSync(file)
|
|
422
474
|
? fs.readFileSync(file, "utf8").split(/\r?\n/).flatMap((line) => { try {
|
|
@@ -426,7 +478,10 @@ export function getFlowRunTimeline(context, input) {
|
|
|
426
478
|
return [];
|
|
427
479
|
} })
|
|
428
480
|
: [];
|
|
429
|
-
|
|
481
|
+
if ((input.hiddenSections ?? []).length > 0) {
|
|
482
|
+
throw new AppError("validation", "Timeline sections are always included", 2, { hidden_sections: input.hiddenSections });
|
|
483
|
+
}
|
|
484
|
+
const hidden = new Set();
|
|
430
485
|
const sessions = index.sessions;
|
|
431
486
|
const elapsedMs = durationMs(index.started_at ?? run.created_at, index.completed_at ?? null);
|
|
432
487
|
const report = {
|
|
@@ -481,6 +536,14 @@ export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
|
481
536
|
appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
|
|
482
537
|
}
|
|
483
538
|
function guidanceForRun(context, run, index) {
|
|
539
|
+
if (run.status === "discarded") {
|
|
540
|
+
return buildRunFlowGuidance({
|
|
541
|
+
stageRuns: index.stage_runs,
|
|
542
|
+
protocolStage: "cancelled",
|
|
543
|
+
runId: run.id,
|
|
544
|
+
runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
|
|
545
|
+
});
|
|
546
|
+
}
|
|
484
547
|
if (run.subject_type === "protocol") {
|
|
485
548
|
try {
|
|
486
549
|
const protocol = requireProtocol(context, run.subject_id, run.project_id);
|
|
@@ -490,7 +553,7 @@ function guidanceForRun(context, run, index) {
|
|
|
490
553
|
protocolStage: state.stage,
|
|
491
554
|
...(state.flow_contract ? { contract: state.flow_contract } : {}),
|
|
492
555
|
runId: run.id,
|
|
493
|
-
runDir: index.run_home?.relative_path ??
|
|
556
|
+
runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id)
|
|
494
557
|
});
|
|
495
558
|
}
|
|
496
559
|
catch (error) {
|
|
@@ -498,11 +561,10 @@ function guidanceForRun(context, run, index) {
|
|
|
498
561
|
throw error;
|
|
499
562
|
}
|
|
500
563
|
}
|
|
501
|
-
return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ??
|
|
564
|
+
return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? path.posix.join("runs", run.id) });
|
|
502
565
|
}
|
|
503
|
-
function
|
|
566
|
+
function persistRunState(context, project, run, index, options = {}) {
|
|
504
567
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
505
|
-
const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
|
|
506
568
|
const previousRuntime = readRuntimeSnapshot(runtimePath);
|
|
507
569
|
const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
|
|
508
570
|
index.runtime_revision = runtimeRevision;
|
|
@@ -511,9 +573,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
|
|
|
511
573
|
history.push(options.flagRevision);
|
|
512
574
|
}
|
|
513
575
|
ensureDir(path.dirname(runtimePath));
|
|
514
|
-
ensureDir(path.dirname(runIndexPath));
|
|
515
576
|
writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
|
|
516
|
-
writeJsonFile(runIndexPath, index);
|
|
517
577
|
context.db.run(`UPDATE flow_runs
|
|
518
578
|
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
519
579
|
updated_at = ?, completed_at = ?
|
|
@@ -523,7 +583,7 @@ function persistRunIndex(context, project, run, index, options = {}) {
|
|
|
523
583
|
index.next_action,
|
|
524
584
|
JSON.stringify(index),
|
|
525
585
|
runtimePath,
|
|
526
|
-
|
|
586
|
+
runtimePath,
|
|
527
587
|
index.updated_at,
|
|
528
588
|
index.completed_at ?? null,
|
|
529
589
|
project.id,
|
|
@@ -539,10 +599,12 @@ function appendRunTimeline(runHome, event) {
|
|
|
539
599
|
const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
|
|
540
600
|
sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
|
|
541
601
|
}
|
|
602
|
+
const safeEvent = sanitizeTimelineValue(event);
|
|
542
603
|
const enriched = {
|
|
543
604
|
event_id: crypto.randomUUID(),
|
|
544
605
|
sequence: sequence + 1,
|
|
545
|
-
|
|
606
|
+
at: typeof safeEvent.at === "string" ? safeEvent.at : new Date().toISOString(),
|
|
607
|
+
...safeEvent
|
|
546
608
|
};
|
|
547
609
|
fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
|
|
548
610
|
}
|
|
@@ -552,7 +614,6 @@ function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = [
|
|
|
552
614
|
return {
|
|
553
615
|
...rest,
|
|
554
616
|
schema_id: runtimeSchemaId,
|
|
555
|
-
run_index_schema_id: index.schema_id,
|
|
556
617
|
runtime_revision: runtimeRevision,
|
|
557
618
|
...(flagRevisionHistory.length > 0 ? { flag_revision_history: flagRevisionHistory.slice(-32) } : {})
|
|
558
619
|
};
|
|
@@ -568,18 +629,26 @@ function readRuntimeSnapshot(file) {
|
|
|
568
629
|
return undefined;
|
|
569
630
|
}
|
|
570
631
|
}
|
|
571
|
-
function
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
632
|
+
function authoritativeIndex(run) {
|
|
633
|
+
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
634
|
+
if (!runtime) {
|
|
635
|
+
throw new AppError("validation", "RUN authority is missing or invalid", 2, { run_id: run.id, path: run.runtime_path });
|
|
636
|
+
}
|
|
637
|
+
return { ...runtime, schema_id: runSchemaId };
|
|
638
|
+
}
|
|
639
|
+
function sanitizeTimelineValue(value, key) {
|
|
640
|
+
if (key && /(secret|password|authorization|cookie|api[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|transcript|prompt|tool[_-]?output|document[_-]?content)/i.test(key)) {
|
|
641
|
+
return "[REDACTED]";
|
|
642
|
+
}
|
|
643
|
+
if (typeof value === "string") {
|
|
644
|
+
return value.length > 512 ? `${value.slice(0, 512)}…[TRUNCATED]` : value;
|
|
581
645
|
}
|
|
582
|
-
|
|
646
|
+
if (Array.isArray(value))
|
|
647
|
+
return value.map((item) => sanitizeTimelineValue(item));
|
|
648
|
+
if (value && typeof value === "object") {
|
|
649
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, sanitizeTimelineValue(entryValue, entryKey)]));
|
|
650
|
+
}
|
|
651
|
+
return value;
|
|
583
652
|
}
|
|
584
653
|
function durationMs(startedAt, completedAt) {
|
|
585
654
|
if (!startedAt || !completedAt)
|
|
@@ -669,13 +738,6 @@ function flowRunSummary(run) {
|
|
|
669
738
|
completed_at: run.completed_at
|
|
670
739
|
};
|
|
671
740
|
}
|
|
672
|
-
function parseRunIndex(text) {
|
|
673
|
-
const index = JSON.parse(text);
|
|
674
|
-
if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId && index.schema_id !== oldestRunSchemaId) {
|
|
675
|
-
throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
|
|
676
|
-
}
|
|
677
|
-
return index;
|
|
678
|
-
}
|
|
679
741
|
function parseRunFlowKind(value) {
|
|
680
742
|
const normalized = value === "mb-sdlc" ? "mb_sdlc" : value;
|
|
681
743
|
if (!allowedRunFlowKinds.includes(normalized)) {
|
|
@@ -763,12 +825,17 @@ function writeJsonFile(file, value) {
|
|
|
763
825
|
fs.renameSync(tmpFile, file);
|
|
764
826
|
}
|
|
765
827
|
function runArtifactRoot(run) {
|
|
766
|
-
return path.dirname(run.
|
|
828
|
+
return run.run_home_path ?? path.dirname(run.runtime_path);
|
|
767
829
|
}
|
|
768
830
|
function gitFacts(workspaceRoot) {
|
|
769
831
|
const read = (args) => {
|
|
770
832
|
const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
|
|
771
833
|
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
772
834
|
};
|
|
773
|
-
|
|
835
|
+
const status = read("status --porcelain --untracked-files=all");
|
|
836
|
+
return {
|
|
837
|
+
branch: read("branch --show-current"),
|
|
838
|
+
head: read("rev-parse HEAD"),
|
|
839
|
+
status: status === null ? "unavailable" : status.length > 0 ? "dirty" : "clean"
|
|
840
|
+
};
|
|
774
841
|
}
|
|
@@ -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
|
}
|