@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
|
@@ -6,10 +6,19 @@ import { parseJsonObject } from "../shared/json.js";
|
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
7
|
import { appendAudit } from "./audit.js";
|
|
8
8
|
import { cancelLaneWaitersForWorker } from "./lanes.js";
|
|
9
|
+
import { checkpointSessionUsage } from "./usage.js";
|
|
10
|
+
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
11
|
+
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
9
12
|
export function registerFlowSession(context, input) {
|
|
10
13
|
const payload = decodeFlowSessionPayload(input);
|
|
11
14
|
const project = requireProjectByRoot(context, resolveProjectRoot(payload.project_root));
|
|
12
15
|
const session = upsertFlowSession(context, project, payload, input.sessionId);
|
|
16
|
+
if (session.run_id)
|
|
17
|
+
appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_bound", session_id: session.session_id });
|
|
18
|
+
if (session.run_id)
|
|
19
|
+
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
20
|
+
if (session.run_id)
|
|
21
|
+
checkpointSessionUsage(context, session, { checkpoint: "run_started", stage: session.current_stage });
|
|
13
22
|
appendAudit(context, {
|
|
14
23
|
projectId: project.id,
|
|
15
24
|
eventType: "flow_session.registered",
|
|
@@ -38,9 +47,21 @@ export function getFlowSessionStatus(context, input) {
|
|
|
38
47
|
export function stopFlowSession(context, input) {
|
|
39
48
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
40
49
|
const session = requireFlowSession(context, project.id, input.sessionId);
|
|
50
|
+
if (session.run_id)
|
|
51
|
+
checkpointSessionUsage(context, session, { checkpoint: "session_stopped", stage: session.current_stage });
|
|
41
52
|
markSessionStopped(context, project, session, input.reason);
|
|
53
|
+
if (session.run_id)
|
|
54
|
+
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
42
55
|
return { ok: true, session: flowSessionById(context, project.id, input.sessionId) };
|
|
43
56
|
}
|
|
57
|
+
export function syncFlowSessionUsage(context, input) {
|
|
58
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
59
|
+
const session = requireFlowSession(context, project.id, input.sessionId);
|
|
60
|
+
if (!session.run_id) {
|
|
61
|
+
return { ok: true, session_id: session.session_id, extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, snapshot: checkpointSessionUsage(context, session, { checkpoint: "manual_sync", stage: session.current_stage }) };
|
|
64
|
+
}
|
|
44
65
|
export function stopMergeWorker(context, input) {
|
|
45
66
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
46
67
|
const sessions = flowSessionsForProject(context, project.id, { workerId: input.workerId }).filter((session) => session.flow_kind === "merge_worker" && ["active", "pending", "stopping"].includes(session.status));
|
|
@@ -101,6 +122,8 @@ export function updateFlowSessionContinuation(context, projectId, sessionId, act
|
|
|
101
122
|
context.db.run(`UPDATE flow_sessions
|
|
102
123
|
SET continuation_count = ?, last_action_hash = ?, updated_at = ?
|
|
103
124
|
WHERE project_id = ? AND session_id = ?`, [nextCount, actionHash, context.now(), projectId, sessionId]);
|
|
125
|
+
if (session.run_id)
|
|
126
|
+
refreshRunSessionProjection(context, projectId, session.run_id);
|
|
104
127
|
return nextCount;
|
|
105
128
|
}
|
|
106
129
|
export function recordPendingFlowSessionBinding(context, project, input) {
|
|
@@ -148,6 +171,8 @@ export function confirmPendingFlowSessionBinding(context, project, input) {
|
|
|
148
171
|
cwd: pending.cwd ?? payload.cwd ?? null,
|
|
149
172
|
transcript_path: pending.transcript_path ?? payload.transcript_path ?? null
|
|
150
173
|
}, input.sessionId);
|
|
174
|
+
if (session.run_id)
|
|
175
|
+
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
151
176
|
context.db.run(`UPDATE pending_flow_session_bindings SET status = 'confirmed', updated_at = ?
|
|
152
177
|
WHERE project_id = ? AND session_id = ?`, [context.now(), project.id, input.sessionId]);
|
|
153
178
|
appendAudit(context, {
|
|
@@ -205,6 +230,12 @@ function normalizeFlowSessionPayload(payload) {
|
|
|
205
230
|
project_root: projectRoot,
|
|
206
231
|
flow_kind: flowKind,
|
|
207
232
|
run_id: stringField(payload, "run_id", false) ?? null,
|
|
233
|
+
parent_session_id: stringField(payload, "parent_session_id", false) ?? null,
|
|
234
|
+
role: stringField(payload, "role", false) ?? null,
|
|
235
|
+
aspect_id: stringField(payload, "aspect_id", false) ?? null,
|
|
236
|
+
plan_item_id: stringField(payload, "plan_item_id", false) ?? null,
|
|
237
|
+
session_kind: sessionKind(payload),
|
|
238
|
+
coverage_units: normalizeCoverageUnits(payload.coverage_units),
|
|
208
239
|
protocol_id: stringField(payload, "protocol_id", false) ?? null,
|
|
209
240
|
worker_id: stringField(payload, "worker_id", false) ?? null,
|
|
210
241
|
workspace_path: stringField(payload, "workspace_path", false) ?? null,
|
|
@@ -221,15 +252,24 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
221
252
|
const sessionId = forcedSessionId ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
|
|
222
253
|
const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
|
|
223
254
|
context.db.run(`INSERT INTO flow_sessions
|
|
224
|
-
(session_id, project_id, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path,
|
|
255
|
+
(session_id, project_id, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
|
|
225
256
|
continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason,
|
|
226
|
-
transcript_path, cwd, metadata_json, created_at, updated_at, stopped_at)
|
|
227
|
-
VALUES (
|
|
257
|
+
transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at)
|
|
258
|
+
VALUES (
|
|
259
|
+
?, ?, ?, ?, 'active',
|
|
260
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL,
|
|
261
|
+
?, ?, ?, ?, ?, ?, NULL
|
|
262
|
+
)
|
|
228
263
|
ON CONFLICT(session_id, project_id) DO UPDATE SET
|
|
229
264
|
project_root = excluded.project_root,
|
|
230
265
|
flow_kind = excluded.flow_kind,
|
|
231
266
|
status = 'active',
|
|
232
267
|
run_id = excluded.run_id,
|
|
268
|
+
parent_session_id = excluded.parent_session_id,
|
|
269
|
+
role = excluded.role,
|
|
270
|
+
aspect_id = excluded.aspect_id,
|
|
271
|
+
plan_item_id = excluded.plan_item_id,
|
|
272
|
+
session_kind = excluded.session_kind,
|
|
233
273
|
protocol_id = excluded.protocol_id,
|
|
234
274
|
worker_id = excluded.worker_id,
|
|
235
275
|
workspace_path = excluded.workspace_path,
|
|
@@ -239,6 +279,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
239
279
|
transcript_path = COALESCE(excluded.transcript_path, transcript_path),
|
|
240
280
|
cwd = COALESCE(excluded.cwd, cwd),
|
|
241
281
|
metadata_json = excluded.metadata_json,
|
|
282
|
+
coverage_units_json = excluded.coverage_units_json,
|
|
242
283
|
updated_at = excluded.updated_at,
|
|
243
284
|
stop_reason = NULL,
|
|
244
285
|
stopped_at = NULL`, [
|
|
@@ -247,6 +288,11 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
247
288
|
project.root,
|
|
248
289
|
payload.flow_kind,
|
|
249
290
|
payload.run_id ?? null,
|
|
291
|
+
payload.parent_session_id ?? null,
|
|
292
|
+
payload.role ?? null,
|
|
293
|
+
payload.aspect_id ?? null,
|
|
294
|
+
payload.plan_item_id ?? null,
|
|
295
|
+
payload.session_kind ?? null,
|
|
250
296
|
payload.protocol_id,
|
|
251
297
|
payload.worker_id,
|
|
252
298
|
workspacePath,
|
|
@@ -256,6 +302,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
256
302
|
payload.transcript_path ?? null,
|
|
257
303
|
payload.cwd ?? null,
|
|
258
304
|
JSON.stringify(payload.metadata ?? {}),
|
|
305
|
+
JSON.stringify(payload.coverage_units ?? []),
|
|
259
306
|
now,
|
|
260
307
|
now
|
|
261
308
|
]);
|
|
@@ -273,6 +320,10 @@ function markSessionStopped(context, project, session, reason) {
|
|
|
273
320
|
context.db.run(`UPDATE flow_sessions
|
|
274
321
|
SET status = 'stopped', stop_reason = ?, updated_at = ?, stopped_at = ?
|
|
275
322
|
WHERE project_id = ? AND session_id = ?`, [reason, now, now, project.id, session.session_id]);
|
|
323
|
+
if (session.run_id)
|
|
324
|
+
appendFlowRunTimelineEvent(context, project.id, session.run_id, { type: "session_stopped", session_id: session.session_id });
|
|
325
|
+
if (session.run_id)
|
|
326
|
+
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
276
327
|
let lockRelease = undefined;
|
|
277
328
|
let waiterCancellation = undefined;
|
|
278
329
|
if (session.worker_id) {
|
|
@@ -364,6 +415,33 @@ function stringField(payload, key, required) {
|
|
|
364
415
|
}
|
|
365
416
|
return undefined;
|
|
366
417
|
}
|
|
418
|
+
function sessionKind(payload) {
|
|
419
|
+
const value = stringField(payload, "session_kind", false);
|
|
420
|
+
if (!value)
|
|
421
|
+
return null;
|
|
422
|
+
if (["orchestrator", "llm_worker", "runtime_worker"].includes(value)) {
|
|
423
|
+
return value;
|
|
424
|
+
}
|
|
425
|
+
throw new AppError("validation", "session_kind is not supported", 2, { session_kind: value });
|
|
426
|
+
}
|
|
427
|
+
function normalizeCoverageUnits(value) {
|
|
428
|
+
if (value === undefined || value === null)
|
|
429
|
+
return [];
|
|
430
|
+
if (!Array.isArray(value)) {
|
|
431
|
+
throw new AppError("validation", "coverage_units must be an array", 2);
|
|
432
|
+
}
|
|
433
|
+
return value.map((item, index) => {
|
|
434
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
435
|
+
throw new AppError("validation", `coverage_units[${index}] must be an object`, 2);
|
|
436
|
+
}
|
|
437
|
+
const object = item;
|
|
438
|
+
if (typeof object.unit_id !== "string" || object.unit_id.length === 0) {
|
|
439
|
+
throw new AppError("validation", `coverage_units[${index}].unit_id is required`, 2);
|
|
440
|
+
}
|
|
441
|
+
const optional = (key) => object[key] === undefined || object[key] === null ? null : typeof object[key] === "string" ? object[key] : (() => { throw new AppError("validation", `coverage_units[${index}].${key} must be a string`, 2); })();
|
|
442
|
+
return { unit_id: object.unit_id, group_id: optional("group_id"), job_id: optional("job_id"), kind: optional("kind") };
|
|
443
|
+
});
|
|
444
|
+
}
|
|
367
445
|
function requiredStringField(payload, key) {
|
|
368
446
|
const value = stringField(payload, key, true);
|
|
369
447
|
if (!value) {
|
package/dist/services/status.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { loadProjectFlowContract } from "../domain/flow-contract.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
4
6
|
import { getCanonStatus } from "./canon.js";
|
|
5
7
|
import { getCliBuildInfo } from "./build-info.js";
|
|
6
8
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
@@ -25,6 +27,7 @@ export function getRuntimeStatus(context, input = {}) {
|
|
|
25
27
|
const compatibilityManifest = resolvedCanonForProject ? readCompatibilityManifest(canon) : null;
|
|
26
28
|
const cliCompatibility = cliCompatibilityVerdict(cli, compatibilityManifest);
|
|
27
29
|
const engineSelection = projectRoot ? selectEngine(context, { projectRoot }) : null;
|
|
30
|
+
const flowContract = projectRoot ? projectFlowContractStatus(projectRoot) : null;
|
|
28
31
|
const registry = input.checkRegistry ? checkNpmRegistry(context, cli.package_name) : undefined;
|
|
29
32
|
return {
|
|
30
33
|
ok: true,
|
|
@@ -45,7 +48,8 @@ export function getRuntimeStatus(context, input = {}) {
|
|
|
45
48
|
status: project?.status ?? null,
|
|
46
49
|
memory_bank: projectVersionStatus?.memory_bank ?? null,
|
|
47
50
|
flow_pack: projectVersionStatus?.flow_pack ?? null,
|
|
48
|
-
drift: projectVersionStatus?.drift ?? null
|
|
51
|
+
drift: projectVersionStatus?.drift ?? null,
|
|
52
|
+
flow_contract: flowContract
|
|
49
53
|
},
|
|
50
54
|
engine: engineSelection
|
|
51
55
|
? {
|
|
@@ -60,6 +64,33 @@ export function getRuntimeStatus(context, input = {}) {
|
|
|
60
64
|
}
|
|
61
65
|
};
|
|
62
66
|
}
|
|
67
|
+
function projectFlowContractStatus(projectRoot) {
|
|
68
|
+
try {
|
|
69
|
+
const contract = loadProjectFlowContract(projectRoot);
|
|
70
|
+
return { status: "valid", id: contract.id, version: contract.version, diagnostic: null, action: null };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (!(error instanceof AppError))
|
|
74
|
+
throw error;
|
|
75
|
+
const file = typeof error.details.file === "string"
|
|
76
|
+
? error.details.file
|
|
77
|
+
: path.join(projectRoot, ".memory-bank", "dd-flow", "flow-contract.json");
|
|
78
|
+
return {
|
|
79
|
+
status: "degraded",
|
|
80
|
+
id: null,
|
|
81
|
+
version: null,
|
|
82
|
+
diagnostic: {
|
|
83
|
+
code: "flow_contract_invalid",
|
|
84
|
+
path: typeof error.details.path === "string" ? error.details.path : "/",
|
|
85
|
+
message: error.message
|
|
86
|
+
},
|
|
87
|
+
action: {
|
|
88
|
+
code: "repair_or_upgrade_flow_contract",
|
|
89
|
+
command: `dd-flow schema validate --schema flow-contract --file "${file}" --project-root "${projectRoot}" --json`
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
63
94
|
function asRecord(value) {
|
|
64
95
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
65
96
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
export function checkpointSessionUsage(context, session, input) {
|
|
4
|
+
const observedAt = context.now();
|
|
5
|
+
const result = readCodexTranscript(session.transcript_path, session.session_id);
|
|
6
|
+
const snapshot = {
|
|
7
|
+
id: `USG-${crypto.randomUUID()}`,
|
|
8
|
+
...session,
|
|
9
|
+
checkpoint: input.checkpoint,
|
|
10
|
+
stage: input.stage ?? null,
|
|
11
|
+
stage_attempt: input.stageAttempt ?? null,
|
|
12
|
+
observed_at: observedAt,
|
|
13
|
+
total_tokens: result.counter?.usage.total_tokens ?? null,
|
|
14
|
+
input_tokens: result.counter?.usage.input_tokens ?? null,
|
|
15
|
+
cached_input_tokens: result.counter?.usage.cached_input_tokens ?? null,
|
|
16
|
+
output_tokens: result.counter?.usage.output_tokens ?? null,
|
|
17
|
+
reasoning_output_tokens: result.counter?.usage.reasoning_output_tokens ?? null,
|
|
18
|
+
source_kind: "codex_transcript_v1",
|
|
19
|
+
token_event_at: result.counter?.token_event_at ?? null,
|
|
20
|
+
turn_id: result.counter?.turn_id ?? null,
|
|
21
|
+
turn_attribution: result.counter?.turn_attribution ?? "unavailable",
|
|
22
|
+
parser_version: 1,
|
|
23
|
+
extraction_status: result.status,
|
|
24
|
+
diagnostic_code: result.diagnostic ?? null
|
|
25
|
+
};
|
|
26
|
+
const previous = context.db.get(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
|
|
27
|
+
FROM flow_run_usage_snapshots s
|
|
28
|
+
LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
|
|
29
|
+
WHERE s.project_id = ? AND s.run_id = ? AND s.session_id = ? AND s.checkpoint = ?
|
|
30
|
+
ORDER BY s.observed_at DESC, s.id DESC LIMIT 1`, [session.project_id, session.run_id, session.session_id, input.checkpoint]);
|
|
31
|
+
if (previous && sameUsageSnapshot(previous, snapshot)) {
|
|
32
|
+
return snapshotForOutput(previous);
|
|
33
|
+
}
|
|
34
|
+
context.db.run(`INSERT INTO flow_run_usage_snapshots
|
|
35
|
+
(id, project_id, run_id, session_id, checkpoint, stage, stage_attempt, observed_at,
|
|
36
|
+
total_tokens, input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens,
|
|
37
|
+
source_kind, token_event_at, turn_id, turn_attribution, parser_version, extraction_status, diagnostic_code, created_at)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
39
|
+
snapshot.id, snapshot.project_id, snapshot.run_id, snapshot.session_id, snapshot.checkpoint, snapshot.stage,
|
|
40
|
+
snapshot.stage_attempt, snapshot.observed_at, snapshot.total_tokens, snapshot.input_tokens,
|
|
41
|
+
snapshot.cached_input_tokens, snapshot.output_tokens, snapshot.reasoning_output_tokens, snapshot.source_kind,
|
|
42
|
+
snapshot.token_event_at, snapshot.turn_id, snapshot.turn_attribution, snapshot.parser_version,
|
|
43
|
+
snapshot.extraction_status, snapshot.diagnostic_code, observedAt
|
|
44
|
+
]);
|
|
45
|
+
return snapshotForOutput(snapshot);
|
|
46
|
+
}
|
|
47
|
+
export function checkpointRunUsage(context, input) {
|
|
48
|
+
const sessions = context.db.all(`SELECT session_id, project_id, run_id, transcript_path, parent_session_id, role, aspect_id, plan_item_id, session_kind
|
|
49
|
+
FROM flow_sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
|
|
50
|
+
return sessions.map((session) => checkpointSessionUsage(context, session, input));
|
|
51
|
+
}
|
|
52
|
+
export function usageForRun(context, input) {
|
|
53
|
+
const sessions = context.db.all(`SELECT session_id, project_id, run_id, transcript_path, parent_session_id, role, aspect_id, plan_item_id, session_kind
|
|
54
|
+
FROM flow_sessions WHERE project_id = ? AND run_id = ?`, [input.projectId, input.runId]);
|
|
55
|
+
const refreshed = sessions.map((session) => checkpointSessionUsage(context, session, { checkpoint: "manual_sync" }));
|
|
56
|
+
const rows = context.db.all(`SELECT s.*, f.parent_session_id, f.role, f.aspect_id, f.plan_item_id, f.session_kind
|
|
57
|
+
FROM flow_run_usage_snapshots s
|
|
58
|
+
LEFT JOIN flow_sessions f ON f.project_id = s.project_id AND f.session_id = s.session_id
|
|
59
|
+
WHERE s.project_id = ? AND s.run_id = ? ORDER BY s.session_id, s.observed_at, s.id`, [input.projectId, input.runId]);
|
|
60
|
+
const deltas = usageDeltas(rows);
|
|
61
|
+
const supported = new Set(["session", "role", "aspect", "plan-item", "stage", "protocol"]);
|
|
62
|
+
const groupBy = supported.has(input.groupBy) ? input.groupBy : "session";
|
|
63
|
+
const groups = new Map();
|
|
64
|
+
for (const delta of deltas) {
|
|
65
|
+
const key = groupKey(delta, groupBy);
|
|
66
|
+
const group = groups.get(key) ?? { tokens: zeroUsage(), snapshots: 0, statuses: {}, transition_buckets: 0 };
|
|
67
|
+
group.snapshots += 1;
|
|
68
|
+
group.statuses[delta.extraction_status] = (group.statuses[delta.extraction_status] ?? 0) + 1;
|
|
69
|
+
if (delta.usage)
|
|
70
|
+
addUsage(group.tokens, delta.usage);
|
|
71
|
+
if (delta.transition)
|
|
72
|
+
group.transition_buckets += 1;
|
|
73
|
+
groups.set(key, group);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
schema_id: "dd-flow/run-usage@1",
|
|
78
|
+
run_id: input.runId,
|
|
79
|
+
source: { kind: "codex_transcript_v1", parser_version: 1, refreshed_sessions: refreshed.length },
|
|
80
|
+
sessions: sessions.map((session) => ({
|
|
81
|
+
session_id: session.session_id, parent_session_id: session.parent_session_id ?? null, role: session.role ?? null,
|
|
82
|
+
aspect_id: session.aspect_id ?? null, plan_item_id: session.plan_item_id ?? null, session_kind: session.session_kind ?? null
|
|
83
|
+
})),
|
|
84
|
+
groups: [...groups.entries()].map(([key, group]) => ({ key, ...group })),
|
|
85
|
+
deltas,
|
|
86
|
+
coverage: coverageForRows(rows)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function readCodexTranscript(transcriptPath, expectedSessionId) {
|
|
90
|
+
if (!transcriptPath)
|
|
91
|
+
return { status: "ephemeral_session", diagnostic: "transcript_path_missing" };
|
|
92
|
+
if (!fs.existsSync(transcriptPath))
|
|
93
|
+
return { status: "transcript_missing", diagnostic: "transcript_path_missing" };
|
|
94
|
+
try {
|
|
95
|
+
const lines = fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/);
|
|
96
|
+
let sessionId;
|
|
97
|
+
let nearestTurn = null;
|
|
98
|
+
let latest;
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
let event;
|
|
103
|
+
try {
|
|
104
|
+
event = JSON.parse(line);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const payload = object(event.payload);
|
|
110
|
+
if (event.type === "session_meta")
|
|
111
|
+
sessionId = stringValue(payload?.id) ?? sessionId;
|
|
112
|
+
if (event.type === "event_msg" && payload?.type === "task_started")
|
|
113
|
+
nearestTurn = stringValue(payload.turn_id) ?? nearestTurn;
|
|
114
|
+
if (event.type !== "event_msg" || payload?.type !== "token_count")
|
|
115
|
+
continue;
|
|
116
|
+
const info = object(payload.info);
|
|
117
|
+
const total = object(info?.total_token_usage);
|
|
118
|
+
const parsed = parseUsage(total);
|
|
119
|
+
if (parsed)
|
|
120
|
+
latest = { usage: parsed, token_event_at: stringValue(event.timestamp) ?? null, turn_id: nearestTurn, turn_attribution: nearestTurn ? "inferred_nearest_task_started" : "unavailable" };
|
|
121
|
+
}
|
|
122
|
+
if (sessionId && sessionId !== expectedSessionId)
|
|
123
|
+
return { status: "session_mismatch", diagnostic: "transcript_session_id_mismatch" };
|
|
124
|
+
if (!latest)
|
|
125
|
+
return { status: "not_yet_emitted", diagnostic: "token_count_not_found" };
|
|
126
|
+
return { status: "measured", counter: latest };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { status: "malformed_source", diagnostic: "transcript_read_failed" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function usageDeltas(rows) {
|
|
133
|
+
const previous = new Map();
|
|
134
|
+
return rows.map((row) => {
|
|
135
|
+
const prior = previous.get(row.session_id);
|
|
136
|
+
previous.set(row.session_id, row);
|
|
137
|
+
const measured = row.extraction_status === "measured" && prior?.extraction_status === "measured";
|
|
138
|
+
const reset = measured && prior ? counterReset(row, prior) : false;
|
|
139
|
+
const usage = measured && prior && !reset ? subtractUsage(row, prior) : null;
|
|
140
|
+
const transition = reset
|
|
141
|
+
? { transition: "counter_reset", attribution: "cross_stage_turn", confidence: "indeterminate" }
|
|
142
|
+
: measured && prior?.stage && row.stage && prior.stage !== row.stage
|
|
143
|
+
? { transition: `${prior.stage} -> ${row.stage}`, attribution: "cross_stage_turn", confidence: "indeterminate" }
|
|
144
|
+
: null;
|
|
145
|
+
return {
|
|
146
|
+
snapshot_id: row.id, session_id: row.session_id, checkpoint: row.checkpoint, observed_at: row.observed_at,
|
|
147
|
+
stage: row.stage, role: row.role ?? null, aspect_id: row.aspect_id ?? null, plan_item_id: row.plan_item_id ?? null,
|
|
148
|
+
extraction_status: reset ? "stale_source" : row.extraction_status, usage: transition ? null : usage,
|
|
149
|
+
...(transition ? transition : {}), source: { kind: row.source_kind, token_event_at: row.token_event_at, turn_id: row.turn_id, turn_attribution: row.turn_attribution }
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function subtractUsage(current, previous) {
|
|
154
|
+
return {
|
|
155
|
+
total_tokens: (current.total_tokens ?? 0) - (previous.total_tokens ?? 0),
|
|
156
|
+
input_tokens: current.input_tokens === null || previous.input_tokens === null ? null : current.input_tokens - previous.input_tokens,
|
|
157
|
+
cached_input_tokens: current.cached_input_tokens === null || previous.cached_input_tokens === null ? null : current.cached_input_tokens - previous.cached_input_tokens,
|
|
158
|
+
output_tokens: current.output_tokens === null || previous.output_tokens === null ? null : current.output_tokens - previous.output_tokens,
|
|
159
|
+
reasoning_output_tokens: current.reasoning_output_tokens === null || previous.reasoning_output_tokens === null ? null : current.reasoning_output_tokens - previous.reasoning_output_tokens
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function counterReset(current, previous) {
|
|
163
|
+
return [
|
|
164
|
+
[current.total_tokens, previous.total_tokens],
|
|
165
|
+
[current.input_tokens, previous.input_tokens],
|
|
166
|
+
[current.cached_input_tokens, previous.cached_input_tokens],
|
|
167
|
+
[current.output_tokens, previous.output_tokens],
|
|
168
|
+
[current.reasoning_output_tokens, previous.reasoning_output_tokens]
|
|
169
|
+
].some(([now, before]) => typeof now === "number" && typeof before === "number" && now < before);
|
|
170
|
+
}
|
|
171
|
+
function groupKey(delta, groupBy) {
|
|
172
|
+
if (groupBy === "stage")
|
|
173
|
+
return String(delta.stage ?? delta.transition ?? "unattributed");
|
|
174
|
+
if (groupBy === "role")
|
|
175
|
+
return delta.role ?? "unassigned";
|
|
176
|
+
if (groupBy === "aspect")
|
|
177
|
+
return delta.aspect_id ?? "unassigned";
|
|
178
|
+
if (groupBy === "plan-item")
|
|
179
|
+
return delta.plan_item_id ?? "unassigned";
|
|
180
|
+
return String(delta.session_id ?? "unknown");
|
|
181
|
+
}
|
|
182
|
+
function coverageForRows(rows) {
|
|
183
|
+
return rows.reduce((result, row) => {
|
|
184
|
+
result[row.extraction_status] = (result[row.extraction_status] ?? 0) + 1;
|
|
185
|
+
return result;
|
|
186
|
+
}, {});
|
|
187
|
+
}
|
|
188
|
+
function snapshotForOutput(snapshot) {
|
|
189
|
+
const safe = { ...snapshot };
|
|
190
|
+
delete safe.transcript_path;
|
|
191
|
+
return { schema_id: "dd-flow/session-usage-snapshot@1", ...safe };
|
|
192
|
+
}
|
|
193
|
+
function sameUsageSnapshot(previous, current) {
|
|
194
|
+
return previous.stage === current.stage
|
|
195
|
+
&& previous.stage_attempt === current.stage_attempt
|
|
196
|
+
&& previous.extraction_status === current.extraction_status
|
|
197
|
+
&& previous.total_tokens === current.total_tokens
|
|
198
|
+
&& previous.input_tokens === current.input_tokens
|
|
199
|
+
&& previous.cached_input_tokens === current.cached_input_tokens
|
|
200
|
+
&& previous.output_tokens === current.output_tokens
|
|
201
|
+
&& previous.reasoning_output_tokens === current.reasoning_output_tokens
|
|
202
|
+
&& previous.token_event_at === current.token_event_at
|
|
203
|
+
&& previous.turn_id === current.turn_id;
|
|
204
|
+
}
|
|
205
|
+
function parseUsage(value) {
|
|
206
|
+
if (!value)
|
|
207
|
+
return undefined;
|
|
208
|
+
const total = numberValue(value.total_tokens);
|
|
209
|
+
if (total === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
return {
|
|
212
|
+
total_tokens: total,
|
|
213
|
+
input_tokens: numberValue(value.input_tokens) ?? null,
|
|
214
|
+
cached_input_tokens: numberValue(value.cached_input_tokens) ?? null,
|
|
215
|
+
output_tokens: numberValue(value.output_tokens) ?? null,
|
|
216
|
+
reasoning_output_tokens: numberValue(value.reasoning_output_tokens) ?? null
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function addUsage(target, source) {
|
|
220
|
+
target.total_tokens += source.total_tokens;
|
|
221
|
+
if (source.input_tokens !== null)
|
|
222
|
+
target.input_tokens += source.input_tokens;
|
|
223
|
+
if (source.cached_input_tokens !== null)
|
|
224
|
+
target.cached_input_tokens += source.cached_input_tokens;
|
|
225
|
+
if (source.output_tokens !== null)
|
|
226
|
+
target.output_tokens += source.output_tokens;
|
|
227
|
+
if (source.reasoning_output_tokens !== null)
|
|
228
|
+
target.reasoning_output_tokens += source.reasoning_output_tokens;
|
|
229
|
+
}
|
|
230
|
+
function zeroUsage() { return { total_tokens: 0, input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 }; }
|
|
231
|
+
function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; }
|
|
232
|
+
function stringValue(value) { return typeof value === "string" && value ? value : undefined; }
|
|
233
|
+
function numberValue(value) { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
|
|
@@ -4,12 +4,13 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { flowContractForState } from "../domain/flow-contract.js";
|
|
5
5
|
import { requireStage } from "../domain/validation.js";
|
|
6
6
|
import { AppError } from "../shared/errors.js";
|
|
7
|
-
import { projectCheckoutRoot } from "../storage/paths.js";
|
|
7
|
+
import { projectCheckoutRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
8
8
|
import { appendAudit } from "./audit.js";
|
|
9
9
|
import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
|
|
10
|
+
import { requireProjectByRoot } from "./projects.js";
|
|
10
11
|
const worktrunkBinEnv = "DD_FLOW_WORKTRUNK_BIN";
|
|
11
12
|
export function planWorktree(context, input) {
|
|
12
|
-
const protocol =
|
|
13
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
13
14
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
14
15
|
const suggestedPath = path.join(projectCheckoutRoot(context.ddFlowHome, protocol.project_id), "worktrees", protocol.id, path.basename(protocol.project_root));
|
|
15
16
|
return {
|
|
@@ -28,8 +29,8 @@ export function planWorktree(context, input) {
|
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
export function createWorktreeRecord(context, input) {
|
|
31
|
-
const protocol =
|
|
32
|
-
const existing = activeWorktreeRecord(context, protocol.id);
|
|
32
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
33
|
+
const existing = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
33
34
|
if (existing) {
|
|
34
35
|
throw new AppError("worktree_already_exists", "Protocol already has an active worktree record", 1, {
|
|
35
36
|
protocol_id: protocol.id,
|
|
@@ -81,15 +82,15 @@ export function createWorktreeRecord(context, input) {
|
|
|
81
82
|
eventType: "worktree.created",
|
|
82
83
|
payload: { protocol_id: protocol.id, feature_branch: input.branch, base: input.base, path: worktreePath }
|
|
83
84
|
});
|
|
84
|
-
return { ok: true, worktree: activeWorktreeRecord(context, protocol.id) };
|
|
85
|
+
return { ok: true, worktree: activeWorktreeRecord(context, protocol.project_id, protocol.id) };
|
|
85
86
|
}
|
|
86
87
|
export function getWorktreeStatus(context, input) {
|
|
87
|
-
const protocol =
|
|
88
|
-
return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.id), worktrunk: detectWorktrunk(context) };
|
|
88
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
89
|
+
return { ok: true, protocol_id: protocol.id, worktree: worktreeRecord(context, protocol.project_id, protocol.id), worktrunk: detectWorktrunk(context) };
|
|
89
90
|
}
|
|
90
91
|
export function bootstrapWorktree(context, input) {
|
|
91
|
-
const protocol =
|
|
92
|
-
const record = activeWorktreeRecord(context, protocol.id);
|
|
92
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
93
|
+
const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
93
94
|
if (!record) {
|
|
94
95
|
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
95
96
|
}
|
|
@@ -101,7 +102,7 @@ export function bootstrapWorktree(context, input) {
|
|
|
101
102
|
};
|
|
102
103
|
context.db.run(`UPDATE worktree_records
|
|
103
104
|
SET bootstrap_status = 'succeeded', last_command_result_json = ?, updated_at = ?
|
|
104
|
-
WHERE protocol_id = ?`, [JSON.stringify(result), now, protocol.id]);
|
|
105
|
+
WHERE project_id = ? AND protocol_id = ?`, [JSON.stringify(result), now, protocol.project_id, protocol.id]);
|
|
105
106
|
const state = readProtocolRuntimeState(context, protocol).state;
|
|
106
107
|
persistProtocolState(context, protocol, {
|
|
107
108
|
...state,
|
|
@@ -117,14 +118,14 @@ export function bootstrapWorktree(context, input) {
|
|
|
117
118
|
eventType: "worktree.bootstrap_succeeded",
|
|
118
119
|
payload: { protocol_id: protocol.id, worktree_path: record.worktree_path }
|
|
119
120
|
});
|
|
120
|
-
return { ok: true, worktree: worktreeRecord(context, protocol.id) };
|
|
121
|
+
return { ok: true, worktree: worktreeRecord(context, protocol.project_id, protocol.id) };
|
|
121
122
|
}
|
|
122
123
|
export function closeWorktree(context, input) {
|
|
123
124
|
if (!["keep", "remove"].includes(input.mode)) {
|
|
124
125
|
throw new AppError("validation", "--mode must be keep or remove", 2);
|
|
125
126
|
}
|
|
126
|
-
const protocol =
|
|
127
|
-
const record = activeWorktreeRecord(context, protocol.id);
|
|
127
|
+
const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
|
|
128
|
+
const record = activeWorktreeRecord(context, protocol.project_id, protocol.id);
|
|
128
129
|
if (!record) {
|
|
129
130
|
throw new AppError("not_found", `Worktree record is not active for protocol: ${protocol.id}`, 1);
|
|
130
131
|
}
|
|
@@ -137,7 +138,7 @@ export function closeWorktree(context, input) {
|
|
|
137
138
|
}
|
|
138
139
|
const now = context.now();
|
|
139
140
|
const nextStatus = input.mode === "keep" ? "kept" : fs.existsSync(record.worktree_path) ? "kept" : "removed";
|
|
140
|
-
context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE protocol_id = ?`, [nextStatus, now, now, protocol.id]);
|
|
141
|
+
context.db.run(`UPDATE worktree_records SET status = ?, updated_at = ?, closed_at = ? WHERE project_id = ? AND protocol_id = ?`, [nextStatus, now, now, protocol.project_id, protocol.id]);
|
|
141
142
|
appendAudit(context, {
|
|
142
143
|
protocolId: protocol.id,
|
|
143
144
|
projectId: protocol.project_id,
|
|
@@ -146,21 +147,25 @@ export function closeWorktree(context, input) {
|
|
|
146
147
|
});
|
|
147
148
|
return {
|
|
148
149
|
ok: true,
|
|
149
|
-
worktree: worktreeRecord(context, protocol.id),
|
|
150
|
+
worktree: worktreeRecord(context, protocol.project_id, protocol.id),
|
|
150
151
|
outcome: {
|
|
151
152
|
status: nextStatus,
|
|
152
153
|
reason: nextStatus === "removed" ? "path_absent" : input.mode === "remove" ? "physical_checkout_kept" : "keep_requested"
|
|
153
154
|
}
|
|
154
155
|
};
|
|
155
156
|
}
|
|
157
|
+
function scopedProtocol(context, projectRoot, protocolId) {
|
|
158
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(projectRoot));
|
|
159
|
+
return requireProtocol(context, protocolId, project.id);
|
|
160
|
+
}
|
|
156
161
|
export function worktreeRecordsForProject(context, projectId) {
|
|
157
162
|
return context.db.all(`SELECT * FROM worktree_records WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
|
|
158
163
|
}
|
|
159
|
-
export function worktreeRecord(context, protocolId) {
|
|
160
|
-
return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ?", [protocolId]);
|
|
164
|
+
export function worktreeRecord(context, projectId, protocolId) {
|
|
165
|
+
return context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
161
166
|
}
|
|
162
|
-
function activeWorktreeRecord(context, protocolId) {
|
|
163
|
-
return context.db.get("SELECT * FROM worktree_records WHERE protocol_id = ? AND status = 'active'", [protocolId]);
|
|
167
|
+
function activeWorktreeRecord(context, projectId, protocolId) {
|
|
168
|
+
return context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ? AND status = 'active'", [projectId, protocolId]);
|
|
164
169
|
}
|
|
165
170
|
function detectWorktrunk(context) {
|
|
166
171
|
const configured = context.env[worktrunkBinEnv];
|