@deksden-com/dd-flow-cli 0.4.0 → 0.4.2
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 +22 -0
- package/README.md +25 -9
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +45 -29
- package/dist/cli/run-cli.js +229 -49
- package/dist/domain/entity-ids.js +4 -4
- package/dist/domain/flow-contract.js +502 -36
- package/dist/domain/validation.js +34 -0
- package/dist/protocol/local-files.js +8 -6
- package/dist/schemas/code-stage-report.schema.json +197 -2
- package/dist/schemas/flow-contract.schema.json +126 -0
- package/dist/schemas/flow-run-index-v3.schema.json +203 -0
- package/dist/schemas/flow-run-index.schema.json +22 -2
- package/dist/schemas/flow-run.schema.json +36 -0
- package/dist/schemas/merge-stage-report.schema.json +213 -2
- package/dist/schemas/plan-stage-report.schema.json +156 -2
- package/dist/schemas/release-impact.schema.json +16 -0
- package/dist/services/audit.js +3 -3
- package/dist/services/branch-context.js +17 -5
- package/dist/services/canon.js +0 -1
- package/dist/services/cleanup.js +6 -6
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/compatibility-preflight.js +3 -75
- package/dist/services/dashboard.js +48 -11
- package/dist/services/engines.js +124 -13
- package/dist/services/hooks.js +6 -6
- package/dist/services/ids.js +40 -49
- package/dist/services/merge-queue.js +33 -26
- package/dist/services/merge-worker.js +2 -1
- package/dist/services/migrations.js +64 -0
- package/dist/services/plans.js +23 -16
- package/dist/services/projects.js +2 -2
- package/dist/services/prompts.js +322 -0
- package/dist/services/protocols.js +77 -46
- package/dist/services/run-projection.js +80 -0
- package/dist/services/runs.js +360 -22
- package/dist/services/schema-validation.js +35 -12
- package/dist/services/sessions.js +81 -3
- package/dist/services/status.js +32 -1
- package/dist/services/usage.js +233 -0
- package/dist/services/worktrees.js +24 -19
- package/dist/storage/database.js +223 -9
- package/package.json +1 -1
package/dist/services/runs.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
3
5
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId } from "../domain/entity-ids.js";
|
|
6
|
+
import { checksumForFlowFlagValues, isFlowFlagDowngrade, loadProjectFlowContract, resolveFlowFlags } from "../domain/flow-contract.js";
|
|
4
7
|
import { AppError } from "../shared/errors.js";
|
|
5
8
|
import { ensureDir, projectRunHome, projectRunIndexPath, projectRunJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
6
9
|
import { appendAudit } from "./audit.js";
|
|
7
10
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
8
11
|
import { buildRunFlowGuidance } from "./flow-guidance.js";
|
|
9
12
|
import { readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
import { checkpointRunUsage, usageForRun } from "./usage.js";
|
|
14
|
+
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
15
|
+
const runSchemaId = "dd-flow/flow-run-index@3";
|
|
16
|
+
const legacyRunSchemaId = "dd-flow/flow-run-index@2";
|
|
17
|
+
const oldestRunSchemaId = "dd-flow/flow-run-index@1";
|
|
18
|
+
const runtimeSchemaId = "dd-flow/flow-run@1";
|
|
12
19
|
const runIdType = "RUN";
|
|
13
20
|
const allowedRunFlowKinds = [
|
|
14
21
|
"mb_sdlc",
|
|
@@ -25,14 +32,24 @@ const allowedRunFlowKinds = [
|
|
|
25
32
|
];
|
|
26
33
|
export function startFlowRun(context, input) {
|
|
27
34
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
35
|
+
const flowContract = loadProjectFlowContract(projectRoot);
|
|
28
36
|
registerProject(context, { root: projectRoot });
|
|
29
37
|
const project = requireProjectByRoot(context, projectRoot);
|
|
30
38
|
const workspaceRoot = resolveWorkspaceRoot(input.workspaceRoot ?? projectRoot);
|
|
31
39
|
const flowKind = parseRunFlowKind(input.flowKind);
|
|
32
40
|
const slug = normalizeSlug(input.slug);
|
|
33
|
-
const runId = nextRunId(context, slug);
|
|
41
|
+
const runId = nextRunId(context, project.id, slug);
|
|
34
42
|
const { shortId } = parseFullEntityId(runId);
|
|
35
43
|
const now = context.now();
|
|
44
|
+
const flowFlags = resolveFlowFlags(flowContract, {
|
|
45
|
+
flowKind,
|
|
46
|
+
...(input.preset ? { preset: input.preset } : {}),
|
|
47
|
+
...(input.taskProfile !== undefined ? { taskProfile: input.taskProfile } : {}),
|
|
48
|
+
...(input.protocolOverrides ? { protocolOverrides: input.protocolOverrides } : {}),
|
|
49
|
+
...(input.runOverrides ? { runOverrides: input.runOverrides } : {}),
|
|
50
|
+
snapshotRevision: 1,
|
|
51
|
+
now
|
|
52
|
+
});
|
|
36
53
|
const runHome = projectRunHome(context.ddFlowHome, project.id, runId);
|
|
37
54
|
const runIndexAbsolute = projectRunIndexPath(context.ddFlowHome, project.id, runId);
|
|
38
55
|
const runDirRelative = path.posix.join("runs", runId);
|
|
@@ -61,7 +78,8 @@ export function startFlowRun(context, input) {
|
|
|
61
78
|
},
|
|
62
79
|
execution: {
|
|
63
80
|
project_root: project.root,
|
|
64
|
-
workspace_root: workspaceRoot
|
|
81
|
+
workspace_root: workspaceRoot,
|
|
82
|
+
git: gitFacts(workspaceRoot)
|
|
65
83
|
},
|
|
66
84
|
legacy: {
|
|
67
85
|
project_tasks_run_dir: null
|
|
@@ -73,11 +91,18 @@ export function startFlowRun(context, input) {
|
|
|
73
91
|
verdict: "pending",
|
|
74
92
|
next_action: input.nextAction ?? null,
|
|
75
93
|
created_at: now,
|
|
76
|
-
updated_at: now
|
|
94
|
+
updated_at: now,
|
|
95
|
+
started_at: now,
|
|
96
|
+
timing_status: "measured",
|
|
97
|
+
runtime_revision: 1,
|
|
98
|
+
snapshot_revision: flowFlags.snapshot_revision,
|
|
99
|
+
snapshot_checksum: flowFlags.snapshot_checksum,
|
|
100
|
+
reconciliation_status: "reconciled",
|
|
101
|
+
flow_flags: flowFlags
|
|
77
102
|
};
|
|
78
103
|
ensureDir(path.dirname(runtimePath));
|
|
79
104
|
ensureDir(runHome);
|
|
80
|
-
writeJsonFile(runtimePath, index);
|
|
105
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, 1));
|
|
81
106
|
writeJsonFile(runIndexAbsolute, index);
|
|
82
107
|
context.db.run(`INSERT INTO flow_runs
|
|
83
108
|
(id, short_id, slug, project_id, project_root, workspace_root, flow_kind, subject_type, subject_id,
|
|
@@ -111,13 +136,167 @@ export function startFlowRun(context, input) {
|
|
|
111
136
|
eventType: "flow_run.started",
|
|
112
137
|
payload: { run_id: runId, flow_kind: flowKind, subject: index.subject, workspace_root: workspaceRoot, run_home: runHome }
|
|
113
138
|
});
|
|
139
|
+
appendRunTimeline(runHome, { at: now, type: "run_started", run_id: runId });
|
|
140
|
+
appendRunTimeline(runHome, {
|
|
141
|
+
at: now,
|
|
142
|
+
type: "flow_flags_resolved",
|
|
143
|
+
run_id: runId,
|
|
144
|
+
snapshot_revision: flowFlags.snapshot_revision,
|
|
145
|
+
snapshot_checksum: flowFlags.snapshot_checksum,
|
|
146
|
+
preset: flowFlags.preset.applied
|
|
147
|
+
});
|
|
114
148
|
return { ok: true, run: flowRunSummary(requireRunById(context, project.id, runId)), index };
|
|
115
149
|
}
|
|
116
150
|
export function getFlowRunStatus(context, input) {
|
|
151
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
152
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
153
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
154
|
+
const refreshedRun = requireRunById(context, project.id, run.id);
|
|
155
|
+
const index = parseRunIndex(refreshedRun.index_json);
|
|
156
|
+
const runtime = readRuntimeSnapshot(refreshedRun.runtime_path);
|
|
157
|
+
return { ok: true, run: flowRunSummary(refreshedRun), index, ...(runtime ? { runtime } : {}), flow_guidance: guidanceForRun(context, refreshedRun, index) };
|
|
158
|
+
}
|
|
159
|
+
export function getFlowRunFlagsStatus(context, input) {
|
|
117
160
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
118
161
|
const run = resolveRun(context, project.id, input.runId);
|
|
119
162
|
const index = parseRunIndex(run.index_json);
|
|
120
|
-
|
|
163
|
+
const runtime = readRuntimeSnapshot(run.runtime_path);
|
|
164
|
+
const flags = runtime?.flow_flags ?? index.flow_flags;
|
|
165
|
+
if (!flags) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
run_id: run.id,
|
|
169
|
+
resolution_status: "legacy_incomplete",
|
|
170
|
+
flow_flags: null,
|
|
171
|
+
snapshot_revision: null,
|
|
172
|
+
snapshot_checksum: null
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
run_id: run.id,
|
|
178
|
+
resolution_status: flags.resolution_status,
|
|
179
|
+
snapshot_revision: flags.snapshot_revision,
|
|
180
|
+
snapshot_checksum: flags.snapshot_checksum,
|
|
181
|
+
flow_flags: flags,
|
|
182
|
+
runtime_revision: runtime?.runtime_revision ?? index.runtime_revision ?? null
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
export function reviseFlowRunFlags(context, input) {
|
|
186
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
187
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
188
|
+
const idempotencyKey = requiredPlain(input.idempotencyKey, "idempotency-key");
|
|
189
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
190
|
+
let committed = false;
|
|
191
|
+
try {
|
|
192
|
+
const existingMutation = context.db.get("SELECT result_json FROM flow_run_flag_mutations WHERE project_id = ? AND run_id = ? AND idempotency_key = ?", [project.id, run.id, idempotencyKey]);
|
|
193
|
+
if (existingMutation) {
|
|
194
|
+
const result = { ...JSON.parse(existingMutation.result_json), idempotent: true };
|
|
195
|
+
context.db.exec("COMMIT");
|
|
196
|
+
committed = true;
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
// Read the authority only after the write lock: two callers with the same
|
|
200
|
+
// expected revision must not both pass the compare-and-set check.
|
|
201
|
+
const lockedRun = requireRunById(context, project.id, run.id);
|
|
202
|
+
const index = parseRunIndex(lockedRun.index_json);
|
|
203
|
+
const runtime = readRuntimeSnapshot(lockedRun.runtime_path);
|
|
204
|
+
const current = runtime?.flow_flags ?? index.flow_flags;
|
|
205
|
+
if (!current) {
|
|
206
|
+
throw new AppError("validation", "Legacy RUN has no flow-flag snapshot; revise is unavailable", 2, { run_id: run.id });
|
|
207
|
+
}
|
|
208
|
+
if (!Number.isInteger(input.expectedRevision) || input.expectedRevision < 1) {
|
|
209
|
+
throw new AppError("validation", "expected-revision must be a positive integer", 2, {
|
|
210
|
+
expected_revision: input.expectedRevision
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (current.snapshot_revision !== input.expectedRevision) {
|
|
214
|
+
throw new AppError("conflict", "Flow-flag snapshot revision is stale", 1, {
|
|
215
|
+
run_id: run.id,
|
|
216
|
+
expected_revision: input.expectedRevision,
|
|
217
|
+
actual_revision: current.snapshot_revision
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const now = context.now();
|
|
221
|
+
const contract = loadProjectFlowContract(lockedRun.project_root);
|
|
222
|
+
const next = resolveFlowFlags(contract, {
|
|
223
|
+
flowKind: index.flow_kind,
|
|
224
|
+
preset: input.preset ?? current.preset.applied,
|
|
225
|
+
runOverrides: input.flags,
|
|
226
|
+
snapshotRevision: input.expectedRevision + 1,
|
|
227
|
+
now
|
|
228
|
+
});
|
|
229
|
+
for (const [key, value] of Object.entries(current.values)) {
|
|
230
|
+
if (!(key in input.flags) && !input.preset)
|
|
231
|
+
next.values[key] = value;
|
|
232
|
+
}
|
|
233
|
+
const downgrades = Object.entries(current.values).flatMap(([key, currentValue]) => {
|
|
234
|
+
const candidate = next.values[key];
|
|
235
|
+
return candidate && isFlowFlagDowngrade(key, candidate.value, currentValue.value)
|
|
236
|
+
? [{ key, currentValue, candidate }]
|
|
237
|
+
: [];
|
|
238
|
+
});
|
|
239
|
+
const reason = input.reason?.trim();
|
|
240
|
+
const floorDowngrades = downgrades.filter(({ currentValue }) => currentValue.source.kind === "escalation");
|
|
241
|
+
if (floorDowngrades.length > 0) {
|
|
242
|
+
throw new AppError("validation", "Flow-flag downgrade would cross an applied mandatory floor", 2, {
|
|
243
|
+
flags: floorDowngrades.map(({ key, currentValue, candidate }) => ({ flag: key, current: currentValue.value, requested: candidate.value }))
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (downgrades.length > 0 && (!input.allowDowngrade || !reason)) {
|
|
247
|
+
throw new AppError("validation", "Flow-flag downgrade requires --allow-downgrade and --reason", 2, {
|
|
248
|
+
flags: downgrades.map(({ key, currentValue, candidate }) => ({ flag: key, current: currentValue.value, requested: candidate.value }))
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (downgrades.length > 0 && reason) {
|
|
252
|
+
for (const { candidate } of downgrades) {
|
|
253
|
+
candidate.rationale = `${candidate.rationale}; explicit downgrade revision: ${reason}`;
|
|
254
|
+
candidate.source = { ...candidate.source, ref: `${candidate.source.ref}#${idempotencyKey}`, revision: next.snapshot_revision };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
next.snapshot_checksum = checksumForFlowFlagValues(next.values);
|
|
258
|
+
index.flow_flags = next;
|
|
259
|
+
index.snapshot_revision = next.snapshot_revision;
|
|
260
|
+
index.snapshot_checksum = next.snapshot_checksum;
|
|
261
|
+
index.reconciliation_status = "reconciled";
|
|
262
|
+
index.updated_at = now;
|
|
263
|
+
const result = {
|
|
264
|
+
ok: true,
|
|
265
|
+
run_id: run.id,
|
|
266
|
+
snapshot_revision: next.snapshot_revision,
|
|
267
|
+
snapshot_checksum: next.snapshot_checksum,
|
|
268
|
+
flow_flags: next,
|
|
269
|
+
...(reason ? { reason } : {})
|
|
270
|
+
};
|
|
271
|
+
persistRunIndex(context, project, run, index, {
|
|
272
|
+
flagRevision: { revision: next.snapshot_revision, checksum: next.snapshot_checksum, at: now, idempotency_key: idempotencyKey }
|
|
273
|
+
});
|
|
274
|
+
context.db.run(`INSERT INTO flow_run_flag_mutations
|
|
275
|
+
(project_id, run_id, idempotency_key, expected_revision, resulting_revision, result_json, created_at)
|
|
276
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [project.id, run.id, idempotencyKey, input.expectedRevision, next.snapshot_revision, JSON.stringify(result), now]);
|
|
277
|
+
context.db.exec("COMMIT");
|
|
278
|
+
committed = true;
|
|
279
|
+
appendAudit(context, {
|
|
280
|
+
projectId: project.id,
|
|
281
|
+
eventType: "flow_run.flags_revised",
|
|
282
|
+
payload: { run_id: run.id, snapshot_revision: next.snapshot_revision, snapshot_checksum: next.snapshot_checksum, ...(reason ? { reason } : {}) }
|
|
283
|
+
});
|
|
284
|
+
appendRunTimeline(runArtifactRoot(run), {
|
|
285
|
+
at: now,
|
|
286
|
+
type: "flow_flags_revised",
|
|
287
|
+
run_id: run.id,
|
|
288
|
+
snapshot_revision: next.snapshot_revision,
|
|
289
|
+
snapshot_checksum: next.snapshot_checksum,
|
|
290
|
+
idempotency_key: idempotencyKey,
|
|
291
|
+
...(reason ? { reason } : {})
|
|
292
|
+
});
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (!committed)
|
|
297
|
+
context.db.exec("ROLLBACK");
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
121
300
|
}
|
|
122
301
|
export function listFlowRuns(context, input) {
|
|
123
302
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -146,7 +325,9 @@ export function attachFlowRunStage(context, input) {
|
|
|
146
325
|
dir,
|
|
147
326
|
status,
|
|
148
327
|
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
149
|
-
updated_at: now
|
|
328
|
+
updated_at: now,
|
|
329
|
+
...(status === "running" && !existing?.started_at ? { started_at: now } : {}),
|
|
330
|
+
...(status === "running" ? { attempt: nextAttempt(existing) } : {})
|
|
150
331
|
};
|
|
151
332
|
upsertStage(index, stageRun);
|
|
152
333
|
index.updated_at = now;
|
|
@@ -156,6 +337,17 @@ export function attachFlowRunStage(context, input) {
|
|
|
156
337
|
eventType: "flow_run.stage_attached",
|
|
157
338
|
payload: { run_id: run.id, stage, dir, status }
|
|
158
339
|
});
|
|
340
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
341
|
+
if (status === "running") {
|
|
342
|
+
context.db.run("UPDATE flow_sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND run_id = ?", [
|
|
343
|
+
stage,
|
|
344
|
+
now,
|
|
345
|
+
project.id,
|
|
346
|
+
run.id
|
|
347
|
+
]);
|
|
348
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
349
|
+
checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_started", stage, stageAttempt: stageRun.attempt ?? null });
|
|
350
|
+
}
|
|
159
351
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
160
352
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
161
353
|
}
|
|
@@ -178,16 +370,20 @@ export function completeFlowRunStage(context, input) {
|
|
|
178
370
|
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
|
|
179
371
|
...(input.report ? { report: input.report } : {}),
|
|
180
372
|
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
181
|
-
updated_at: now
|
|
373
|
+
updated_at: now,
|
|
374
|
+
...(status === "done" || status === "blocked" || status === "failed" || status === "skipped" ? { completed_at: now } : {})
|
|
182
375
|
};
|
|
183
376
|
upsertStage(index, stageRun);
|
|
184
377
|
index.updated_at = now;
|
|
185
378
|
persistRunIndex(context, project, run, index);
|
|
379
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
186
380
|
appendAudit(context, {
|
|
187
381
|
projectId: project.id,
|
|
188
382
|
eventType: "flow_run.stage_completed",
|
|
189
383
|
payload: { run_id: run.id, stage, status, stage_report: input.stageReport ?? null, data: input.data ?? null }
|
|
190
384
|
});
|
|
385
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "stage_completed", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
386
|
+
checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "stage_finished", stage, stageAttempt: stageRun.attempt ?? null });
|
|
191
387
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
192
388
|
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
193
389
|
}
|
|
@@ -205,18 +401,89 @@ export function completeFlowRun(context, input) {
|
|
|
205
401
|
index.completed_at = now;
|
|
206
402
|
}
|
|
207
403
|
persistRunIndex(context, project, run, index);
|
|
404
|
+
refreshRunSessionProjection(context, project.id, run.id);
|
|
208
405
|
appendAudit(context, {
|
|
209
406
|
projectId: project.id,
|
|
210
407
|
eventType: "flow_run.completed",
|
|
211
408
|
payload: { run_id: run.id, status, verdict: index.verdict, next_action: index.next_action }
|
|
212
409
|
});
|
|
410
|
+
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_completed", run_id: run.id, status });
|
|
411
|
+
checkpointRunUsage(context, { projectId: project.id, runId: run.id, checkpoint: "run_finished" });
|
|
213
412
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
214
413
|
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
215
414
|
}
|
|
415
|
+
const timelineSections = ["stages", "events", "sessions", "usage", "artifacts"];
|
|
416
|
+
export function getFlowRunTimeline(context, input) {
|
|
417
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
418
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
419
|
+
const index = parseRunIndex(run.index_json);
|
|
420
|
+
const file = path.join(runArtifactRoot(run), "timeline.jsonl");
|
|
421
|
+
const events = fs.existsSync(file)
|
|
422
|
+
? fs.readFileSync(file, "utf8").split(/\r?\n/).flatMap((line) => { try {
|
|
423
|
+
return line.trim() ? [JSON.parse(line)] : [];
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return [];
|
|
427
|
+
} })
|
|
428
|
+
: [];
|
|
429
|
+
const hidden = parseHiddenTimelineSections(input.hiddenSections ?? []);
|
|
430
|
+
const sessions = index.sessions;
|
|
431
|
+
const elapsedMs = durationMs(index.started_at ?? run.created_at, index.completed_at ?? null);
|
|
432
|
+
const report = {
|
|
433
|
+
ok: true,
|
|
434
|
+
schema_id: "dd-flow/run-timeline@2",
|
|
435
|
+
run_id: run.id,
|
|
436
|
+
timing_status: index.timing_status ?? "legacy_incomplete",
|
|
437
|
+
hidden_sections: [...hidden],
|
|
438
|
+
available_sections: timelineSections,
|
|
439
|
+
summary: {
|
|
440
|
+
flow_kind: index.flow_kind,
|
|
441
|
+
subject: index.subject,
|
|
442
|
+
status: index.status,
|
|
443
|
+
verdict: index.verdict,
|
|
444
|
+
started_at: index.started_at ?? null,
|
|
445
|
+
completed_at: index.completed_at ?? null,
|
|
446
|
+
elapsed_ms: elapsedMs,
|
|
447
|
+
stage_count: index.stage_runs.length,
|
|
448
|
+
event_count: events.length,
|
|
449
|
+
session_count: sessions.length
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
if (!hidden.has("stages"))
|
|
453
|
+
report.stages = index.stage_runs.map((stage) => ({
|
|
454
|
+
...stage,
|
|
455
|
+
elapsed_ms: durationMs(stage.started_at ?? null, stage.completed_at ?? null)
|
|
456
|
+
}));
|
|
457
|
+
if (!hidden.has("events"))
|
|
458
|
+
report.events = events;
|
|
459
|
+
if (!hidden.has("sessions"))
|
|
460
|
+
report.sessions = sessions;
|
|
461
|
+
if (!hidden.has("usage"))
|
|
462
|
+
report.usage = usageForRun(context, { projectId: project.id, runId: run.id, groupBy: "session" });
|
|
463
|
+
if (!hidden.has("artifacts"))
|
|
464
|
+
report.artifacts = index.stage_runs.map((stage) => ({
|
|
465
|
+
stage: stage.stage,
|
|
466
|
+
dir: stage.dir,
|
|
467
|
+
stage_report: stage.stage_report ?? null,
|
|
468
|
+
data: stage.data ?? null,
|
|
469
|
+
report: stage.report ?? null,
|
|
470
|
+
artifact_aliases: stage.artifact_aliases ?? []
|
|
471
|
+
}));
|
|
472
|
+
return report;
|
|
473
|
+
}
|
|
474
|
+
export function getFlowRunUsage(context, input) {
|
|
475
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
476
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
477
|
+
return usageForRun(context, { projectId: project.id, runId: run.id, groupBy: input.groupBy ?? "session" });
|
|
478
|
+
}
|
|
479
|
+
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
480
|
+
const run = requireRunById(context, projectId, runId);
|
|
481
|
+
appendRunTimeline(runArtifactRoot(run), { run_id: run.id, ...event });
|
|
482
|
+
}
|
|
216
483
|
function guidanceForRun(context, run, index) {
|
|
217
484
|
if (run.subject_type === "protocol") {
|
|
218
485
|
try {
|
|
219
|
-
const protocol = requireProtocol(context, run.subject_id);
|
|
486
|
+
const protocol = requireProtocol(context, run.subject_id, run.project_id);
|
|
220
487
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
221
488
|
return buildRunFlowGuidance({
|
|
222
489
|
stageRuns: index.stage_runs,
|
|
@@ -233,12 +500,19 @@ function guidanceForRun(context, run, index) {
|
|
|
233
500
|
}
|
|
234
501
|
return buildRunFlowGuidance({ stageRuns: index.stage_runs, runId: run.id, runDir: index.run_home?.relative_path ?? index.workspace.run_dir });
|
|
235
502
|
}
|
|
236
|
-
function persistRunIndex(context, project, run, index) {
|
|
503
|
+
function persistRunIndex(context, project, run, index, options = {}) {
|
|
237
504
|
const runtimePath = run.runtime_path || projectRunJsonPath(context.ddFlowHome, project.id, run.id);
|
|
238
505
|
const runIndexPath = run.run_index_path || projectRunIndexPath(context.ddFlowHome, project.id, run.id);
|
|
506
|
+
const previousRuntime = readRuntimeSnapshot(runtimePath);
|
|
507
|
+
const runtimeRevision = Math.max(index.runtime_revision ?? 0, previousRuntime?.runtime_revision ?? 0) + 1;
|
|
508
|
+
index.runtime_revision = runtimeRevision;
|
|
509
|
+
const history = [...(previousRuntime?.flag_revision_history ?? [])];
|
|
510
|
+
if (options.flagRevision) {
|
|
511
|
+
history.push(options.flagRevision);
|
|
512
|
+
}
|
|
239
513
|
ensureDir(path.dirname(runtimePath));
|
|
240
514
|
ensureDir(path.dirname(runIndexPath));
|
|
241
|
-
writeJsonFile(runtimePath, index);
|
|
515
|
+
writeJsonFile(runtimePath, runtimeSnapshotForIndex(index, runtimeRevision, history));
|
|
242
516
|
writeJsonFile(runIndexPath, index);
|
|
243
517
|
context.db.run(`UPDATE flow_runs
|
|
244
518
|
SET status = ?, verdict = ?, next_action = ?, index_json = ?, runtime_path = ?, run_index_path = ?,
|
|
@@ -256,6 +530,69 @@ function persistRunIndex(context, project, run, index) {
|
|
|
256
530
|
run.id
|
|
257
531
|
]);
|
|
258
532
|
}
|
|
533
|
+
function appendRunTimeline(runHome, event) {
|
|
534
|
+
ensureDir(runHome);
|
|
535
|
+
const timelinePath = path.join(runHome, "timeline.jsonl");
|
|
536
|
+
let sequence = 0;
|
|
537
|
+
if (fs.existsSync(timelinePath)) {
|
|
538
|
+
const lines = fs.readFileSync(timelinePath, "utf8").trim().split(/\r?\n/).filter(Boolean);
|
|
539
|
+
const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : undefined;
|
|
540
|
+
sequence = typeof last?.sequence === "number" ? last.sequence : lines.length;
|
|
541
|
+
}
|
|
542
|
+
const enriched = {
|
|
543
|
+
event_id: crypto.randomUUID(),
|
|
544
|
+
sequence: sequence + 1,
|
|
545
|
+
...event
|
|
546
|
+
};
|
|
547
|
+
fs.appendFileSync(timelinePath, `${JSON.stringify(enriched)}\n`);
|
|
548
|
+
}
|
|
549
|
+
function runtimeSnapshotForIndex(index, runtimeRevision, flagRevisionHistory = []) {
|
|
550
|
+
const rest = { ...index };
|
|
551
|
+
Reflect.deleteProperty(rest, "schema_id");
|
|
552
|
+
return {
|
|
553
|
+
...rest,
|
|
554
|
+
schema_id: runtimeSchemaId,
|
|
555
|
+
run_index_schema_id: index.schema_id,
|
|
556
|
+
runtime_revision: runtimeRevision,
|
|
557
|
+
...(flagRevisionHistory.length > 0 ? { flag_revision_history: flagRevisionHistory.slice(-32) } : {})
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function readRuntimeSnapshot(file) {
|
|
561
|
+
if (!file || !fs.existsSync(file))
|
|
562
|
+
return undefined;
|
|
563
|
+
try {
|
|
564
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
565
|
+
return value.schema_id === runtimeSchemaId ? value : undefined;
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return undefined;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function parseHiddenTimelineSections(values) {
|
|
572
|
+
const hidden = new Set();
|
|
573
|
+
for (const value of values) {
|
|
574
|
+
if (!timelineSections.includes(value)) {
|
|
575
|
+
throw new AppError("validation", "--hide must name a timeline section", 2, {
|
|
576
|
+
value,
|
|
577
|
+
allowed: timelineSections
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
hidden.add(value);
|
|
581
|
+
}
|
|
582
|
+
return hidden;
|
|
583
|
+
}
|
|
584
|
+
function durationMs(startedAt, completedAt) {
|
|
585
|
+
if (!startedAt || !completedAt)
|
|
586
|
+
return null;
|
|
587
|
+
const value = Date.parse(completedAt) - Date.parse(startedAt);
|
|
588
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
589
|
+
}
|
|
590
|
+
function nextAttempt(existing) {
|
|
591
|
+
if (!existing?.attempt)
|
|
592
|
+
return "try-001";
|
|
593
|
+
const number = Number(existing.attempt.replace("try-", ""));
|
|
594
|
+
return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
|
|
595
|
+
}
|
|
259
596
|
function upsertStage(index, stageRun) {
|
|
260
597
|
const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
|
|
261
598
|
if (position === -1) {
|
|
@@ -292,8 +629,8 @@ function requireRunById(context, projectId, runId) {
|
|
|
292
629
|
}
|
|
293
630
|
return run;
|
|
294
631
|
}
|
|
295
|
-
function nextRunId(context, slug) {
|
|
296
|
-
const rows = context.db.all("SELECT id FROM flow_runs WHERE id LIKE 'RUN-%'");
|
|
632
|
+
function nextRunId(context, projectId, slug) {
|
|
633
|
+
const rows = context.db.all("SELECT id FROM flow_runs WHERE project_id = ? AND id LIKE 'RUN-%'", [projectId]);
|
|
297
634
|
const max = rows.reduce((value, row) => {
|
|
298
635
|
try {
|
|
299
636
|
const parsed = parseFullEntityId(row.id);
|
|
@@ -303,13 +640,7 @@ function nextRunId(context, slug) {
|
|
|
303
640
|
return value;
|
|
304
641
|
}
|
|
305
642
|
}, 0);
|
|
306
|
-
|
|
307
|
-
const id = formatFullId(runIdType, sequence, slug);
|
|
308
|
-
if (!context.db.get("SELECT id FROM flow_runs WHERE id = ?", [id])) {
|
|
309
|
-
return id;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
throw new AppError("validation", "RUN id sequence exhausted", 2);
|
|
643
|
+
return formatFullId(runIdType, max + 1, slug);
|
|
313
644
|
}
|
|
314
645
|
function flowRunSummary(run) {
|
|
315
646
|
return {
|
|
@@ -340,7 +671,7 @@ function flowRunSummary(run) {
|
|
|
340
671
|
}
|
|
341
672
|
function parseRunIndex(text) {
|
|
342
673
|
const index = JSON.parse(text);
|
|
343
|
-
if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId) {
|
|
674
|
+
if (index.schema_id !== runSchemaId && index.schema_id !== legacyRunSchemaId && index.schema_id !== oldestRunSchemaId) {
|
|
344
675
|
throw new AppError("validation", `Invalid run index schema: ${String(index.schema_id)}`, 2);
|
|
345
676
|
}
|
|
346
677
|
return index;
|
|
@@ -434,3 +765,10 @@ function writeJsonFile(file, value) {
|
|
|
434
765
|
function runArtifactRoot(run) {
|
|
435
766
|
return path.dirname(run.run_index_path);
|
|
436
767
|
}
|
|
768
|
+
function gitFacts(workspaceRoot) {
|
|
769
|
+
const read = (args) => {
|
|
770
|
+
const result = spawnSync("git", ["-C", workspaceRoot, ...args.split(" ")], { encoding: "utf8" });
|
|
771
|
+
return result.status === 0 ? result.stdout.trim() || null : null;
|
|
772
|
+
};
|
|
773
|
+
return { branch: read("branch --show-current"), head: read("rev-parse HEAD") };
|
|
774
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { Ajv } from "ajv/dist/ajv.js";
|
|
5
|
+
import { normalizeFlowContract } from "../domain/flow-contract.js";
|
|
5
6
|
import { AppError } from "../shared/errors.js";
|
|
6
7
|
const schemaNamePattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
7
8
|
const mandatoryMbUpgradeReviewAspectIds = [
|
|
@@ -34,15 +35,15 @@ export function validateSchema(options) {
|
|
|
34
35
|
if (!schemaNamePattern.test(options.schemaName)) {
|
|
35
36
|
throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
|
|
36
37
|
}
|
|
37
|
-
const schemaResolution = resolveSchema(options);
|
|
38
|
-
const schema = readJson(schemaResolution.path, "schema");
|
|
39
38
|
const filePath = path.resolve(options.file);
|
|
40
39
|
const data = readJson(filePath, "input file");
|
|
40
|
+
const schemaResolution = resolveSchema(options, data);
|
|
41
|
+
const schema = readJson(schemaResolution.path, "schema");
|
|
41
42
|
const ajv = new Ajv({ allErrors: true, strict: false, validateFormats: false });
|
|
42
43
|
const validate = ajv.compile(schema);
|
|
43
44
|
const valid = validate(data);
|
|
44
45
|
const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
|
|
45
|
-
const semanticErrors = validateSemanticSchema(options.schemaName, data);
|
|
46
|
+
const semanticErrors = valid ? validateSemanticSchema(options.schemaName, data) : [];
|
|
46
47
|
const allErrors = [...errors, ...semanticErrors];
|
|
47
48
|
if (allErrors.length > 0) {
|
|
48
49
|
throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
|
|
@@ -58,16 +59,23 @@ export function validateSchema(options) {
|
|
|
58
59
|
errors: []
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
|
-
function resolveSchema(options) {
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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`];
|
|
67
68
|
const projectRoot = path.resolve(options.projectRoot ?? process.cwd());
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const roots = [
|
|
70
|
+
...(options.schemaDir ? [{ directory: path.resolve(options.schemaDir), source: "schema_dir" }] : []),
|
|
71
|
+
{ directory: path.join(projectRoot, ".memory-bank", "dd-flow", "schemas"), source: "project" },
|
|
72
|
+
{ directory: path.join(projectRoot, "dd-flow", "schemas"), source: "canonical" },
|
|
73
|
+
{ directory: bundledSchemaDir(), source: "bundled" }
|
|
74
|
+
];
|
|
75
|
+
const candidates = roots.flatMap((root) => fileNames.map((fileName) => ({
|
|
76
|
+
path: path.join(root.directory, fileName),
|
|
77
|
+
source: root.source
|
|
78
|
+
})));
|
|
71
79
|
const found = candidates.find((candidate) => fs.existsSync(candidate.path));
|
|
72
80
|
if (!found) {
|
|
73
81
|
throw new AppError("schema_not_found", `Schema not found: ${options.schemaName}`, 2, {
|
|
@@ -121,6 +129,21 @@ function validateSemanticSchema(schemaName, data) {
|
|
|
121
129
|
if (schemaName === "mb-sdlc-review-report") {
|
|
122
130
|
return validateMbSdlcReviewReport(root);
|
|
123
131
|
}
|
|
132
|
+
if (schemaName === "flow-contract") {
|
|
133
|
+
try {
|
|
134
|
+
normalizeFlowContract(root);
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (!(error instanceof AppError))
|
|
139
|
+
throw error;
|
|
140
|
+
return [{
|
|
141
|
+
path: typeof error.details.path === "string" ? error.details.path : "/",
|
|
142
|
+
message: error.message,
|
|
143
|
+
keyword: "dd-flow/flow-contract-semantic"
|
|
144
|
+
}];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
124
147
|
return [];
|
|
125
148
|
}
|
|
126
149
|
function validateMbUpgradeReviewData(root) {
|