@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74
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/dist/build-info.json +3 -3
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +15 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
- package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
- package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
- package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
- package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
- package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/services/code-checks.js +120 -19
- package/dist/services/controller-fanout.js +11 -0
- package/dist/services/engines.js +40 -9
- package/dist/services/eval-snapshots.js +30 -47
- package/dist/services/harness-adapter.js +12 -6
- package/dist/services/lifecycle-invocations.js +12 -8
- package/dist/services/native-session-control.js +6 -0
- package/dist/services/projects.js +6 -2
- package/dist/services/recovery-observation-budget.js +9 -1
- package/dist/services/run-control-worker.js +19 -0
- package/dist/services/run-control.js +87 -13
- package/dist/services/run-controller-adapter.js +134 -7
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/runs.js +1 -11
- package/dist/services/schema-validation.js +1 -1
- package/dist/services/stage-lifecycle.js +14 -2
- package/dist/services/usage.js +20 -87
- package/dist/services/vnext-code-review.js +35 -27
- package/dist/services/vnext-code.js +62 -26
- package/dist/services/vnext-merge.js +8 -8
- package/dist/services/vnext-plan-review.js +16 -16
- package/dist/services/vnext-plan.js +51 -11
- package/dist/services/work-registry.js +48 -27
- package/dist/shared/errors.js +12 -0
- package/package.json +1 -1
|
@@ -86,7 +86,7 @@ function isRunEngineBinding(value) {
|
|
|
86
86
|
&& typeof record.run_id === "string"
|
|
87
87
|
&& typeof record.project_root === "string"
|
|
88
88
|
&& typeof record.bound_at === "string"
|
|
89
|
-
&& ["run_creation", "legacy_recovery"].includes(String(record.source))
|
|
89
|
+
&& ["run_creation", "legacy_recovery", "fork_upgrade"].includes(String(record.source))
|
|
90
90
|
&& typeof record.reason === "string"
|
|
91
91
|
&& Boolean(engine && typeof engine === "object" && !Array.isArray(engine)
|
|
92
92
|
&& typeof engine.package_name === "string"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createContext } from "../runtime/context.js";
|
|
5
|
+
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { canonicalPath } from "../storage/paths.js";
|
|
7
|
+
import { installCurrentEngine, upgradeForkedRunEngine } from "./engines.js";
|
|
8
|
+
import { restoreEvalRunSnapshot } from "./eval-snapshots.js";
|
|
9
|
+
/** Restore a sealed stage boundary into its own home and replace only its
|
|
10
|
+
* copied engine binding. The original snapshot and RUN are read-only. */
|
|
11
|
+
export function forkRunFromSnapshot(context, input) {
|
|
12
|
+
const requestId = requireId(input.requestId, "request-id");
|
|
13
|
+
const snapshot = canonicalPath(input.snapshot);
|
|
14
|
+
const output = path.resolve(input.output);
|
|
15
|
+
if (snapshot === output || snapshot.startsWith(`${output}${path.sep}`) || output.startsWith(`${snapshot}${path.sep}`)) {
|
|
16
|
+
throw new AppError("fork_path_conflict", "Fork output must not contain or be contained by its source snapshot", 2, { snapshot, output });
|
|
17
|
+
}
|
|
18
|
+
const manifestFile = path.join(snapshot, "snapshot.json");
|
|
19
|
+
const manifest = readJson(manifestFile);
|
|
20
|
+
if (manifest.schema_id !== "dd-flow/eval-run-snapshot@5" || manifest.purpose !== "stage_entry" || typeof manifest.stage_entry !== "string" || typeof manifest.run_id !== "string") {
|
|
21
|
+
throw new AppError("fork_checkpoint_invalid", "Fork requires a sealed stage-entry snapshot", 2, { snapshot });
|
|
22
|
+
}
|
|
23
|
+
const intent = {
|
|
24
|
+
schema_id: "dd-flow/run-fork-intent@1",
|
|
25
|
+
request_id: requestId,
|
|
26
|
+
snapshot,
|
|
27
|
+
snapshot_sha256: fileHash(manifestFile),
|
|
28
|
+
engine_version: requireText(input.engineVersion, "engine-version"),
|
|
29
|
+
integrity_checksum: input.integrityChecksum ?? null,
|
|
30
|
+
target_stage: manifest.stage_entry
|
|
31
|
+
};
|
|
32
|
+
const intentFile = path.join(output, ".fork-intent.json");
|
|
33
|
+
const receiptFile = path.join(output, "fork.json");
|
|
34
|
+
if (fs.existsSync(receiptFile)) {
|
|
35
|
+
const receipt = readJson(receiptFile);
|
|
36
|
+
if (hash(receipt.intent) !== hash(intent))
|
|
37
|
+
throw new AppError("fork_request_conflict", "Fork output already belongs to another request", 1, { output });
|
|
38
|
+
return receipt;
|
|
39
|
+
}
|
|
40
|
+
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
|
|
41
|
+
if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
|
|
42
|
+
throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
|
|
43
|
+
const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home"].includes(name));
|
|
44
|
+
if (unknown.length)
|
|
45
|
+
throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
|
|
46
|
+
for (const name of ["project", "dd-flow-home"])
|
|
47
|
+
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
50
|
+
writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
|
|
51
|
+
const projectRoot = path.join(output, "project");
|
|
52
|
+
const home = path.join(output, "dd-flow-home");
|
|
53
|
+
fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
|
|
54
|
+
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
55
|
+
const restored = restoreEvalRunSnapshot(targetContext, { snapshot, projectRoot });
|
|
56
|
+
const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
57
|
+
try {
|
|
58
|
+
const runHome = String(restored.run_home);
|
|
59
|
+
const previousBinding = readJson(path.join(runHome, "engine-binding.json"));
|
|
60
|
+
const lineage = {
|
|
61
|
+
schema_id: "dd-flow/run-fork-lineage@1",
|
|
62
|
+
request_id: requestId,
|
|
63
|
+
source_snapshot: snapshot,
|
|
64
|
+
source_snapshot_sha256: intent.snapshot_sha256,
|
|
65
|
+
source_run_id: manifest.run_id,
|
|
66
|
+
target_stage: manifest.stage_entry,
|
|
67
|
+
inherited_engine: previousBinding,
|
|
68
|
+
created_at: restoredContext.now()
|
|
69
|
+
};
|
|
70
|
+
writeJsonAtomic(path.join(runHome, "fork-lineage.json"), lineage);
|
|
71
|
+
// Install the immutable artifact belonging to the currently executing
|
|
72
|
+
// CLI into the new home. A different exact version must already be
|
|
73
|
+
// supplied as an installed engine artifact for this fork home.
|
|
74
|
+
installCurrentEngine(restoredContext, { forRouting: true });
|
|
75
|
+
const binding = upgradeForkedRunEngine(restoredContext, {
|
|
76
|
+
projectRoot,
|
|
77
|
+
runId: String(restored.run_id),
|
|
78
|
+
engineVersion: intent.engine_version,
|
|
79
|
+
...(intent.integrity_checksum ? { integrityChecksum: intent.integrity_checksum } : {}),
|
|
80
|
+
reason: `Fork ${requestId} from sealed checkpoint ${intent.snapshot_sha256}.`
|
|
81
|
+
});
|
|
82
|
+
const receipt = {
|
|
83
|
+
ok: true,
|
|
84
|
+
schema_id: "dd-flow/run-fork@1",
|
|
85
|
+
status: "ready",
|
|
86
|
+
intent,
|
|
87
|
+
fork: {
|
|
88
|
+
project_root: projectRoot,
|
|
89
|
+
dd_flow_home: home,
|
|
90
|
+
run_id: restored.run_id,
|
|
91
|
+
run_home: runHome,
|
|
92
|
+
workspace_root: restored.workspace_root,
|
|
93
|
+
target_stage: manifest.stage_entry,
|
|
94
|
+
engine: binding.engine,
|
|
95
|
+
lineage_file: path.join(runHome, "fork-lineage.json")
|
|
96
|
+
},
|
|
97
|
+
next_command: `dd-flow stage start ${restored.run_id} --stage ${manifest.stage_entry} --project-root ${JSON.stringify(projectRoot)} --json`
|
|
98
|
+
};
|
|
99
|
+
writeJsonAtomic(receiptFile, receipt);
|
|
100
|
+
return receipt;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
restoredContext.db.close?.();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readJson(file) {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new AppError("fork_checkpoint_invalid", "Fork metadata is not valid JSON", 2, { file, cause: String(error) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function writeJsonAtomic(file, value) {
|
|
115
|
+
const temporary = `${file}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
116
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
117
|
+
fs.renameSync(temporary, file);
|
|
118
|
+
}
|
|
119
|
+
function hash(value) { return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); }
|
|
120
|
+
function fileHash(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
121
|
+
function requireText(value, name) { const text = value.trim(); if (!text)
|
|
122
|
+
throw new AppError("usage", `Missing required value: ${name}`, 2); return text; }
|
|
123
|
+
function requireId(value, name) { const text = requireText(value, name); if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text))
|
|
124
|
+
throw new AppError("usage", `Invalid ${name}`, 2); return text; }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ToolObservations, replayToolJournal } from "../harness-runtime/lib/tool-observations.mjs";
|
|
4
|
+
import { harnessKey } from "./harness-config.js";
|
|
5
|
+
/** Evidence has ancestry, not executable ownership. Never open a source home. */
|
|
6
|
+
export function runObservations(context, projectId, runId) {
|
|
7
|
+
const home = fs.realpathSync(context.ddFlowHome), sources = new Map(), reasons = [];
|
|
8
|
+
const sessions = context.db.all("SELECT session_id, provider_session_id, harness, transcript_path FROM sessions WHERE project_id = ? AND run_id = ?", [projectId, runId]);
|
|
9
|
+
const inside = (root, file) => { const p = path.relative(root, file); return p !== "" && p !== ".." && !p.startsWith(`..${path.sep}`) && !path.isAbsolute(p); };
|
|
10
|
+
const add = (id, nativeId, journal, origin, provenance, harness) => {
|
|
11
|
+
if (sources.has(id))
|
|
12
|
+
return;
|
|
13
|
+
const candidates = sessions.filter(s => (s.provider_session_id ?? s.session_id) === nativeId && (!harness || s.harness === harness));
|
|
14
|
+
if (candidates.length > 1) {
|
|
15
|
+
reasons.push("journal_session_ambiguous");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const owner = candidates[0];
|
|
19
|
+
if (!owner)
|
|
20
|
+
return;
|
|
21
|
+
const source = { id, provenance, session: { harness_id: owner.harness, session_id: nativeId }, path: null, status: "unavailable" };
|
|
22
|
+
sources.set(id, source);
|
|
23
|
+
if (typeof journal !== "string" || !path.isAbsolute(journal) || !inside(origin, journal)) {
|
|
24
|
+
source.reason = "journal_outside_owned_home";
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
source.path = path.relative(origin, journal);
|
|
28
|
+
const file = path.resolve(home, source.path);
|
|
29
|
+
try {
|
|
30
|
+
if (!inside(home, fs.realpathSync(file))) {
|
|
31
|
+
source.reason = "journal_outside_owned_home";
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!fs.statSync(file).isFile()) {
|
|
35
|
+
source.reason = "journal_not_file";
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
source.status = "available";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
source.reason = "journal_missing";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const controllers = (rows, origin, provenance) => {
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
if (row.project_id !== projectId || row.run_id !== runId)
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
for (const session of JSON.parse(row.state_json).sessions ?? [])
|
|
50
|
+
add(`${row.controller_id}:${session.id}`, session.id, session.journal, origin, provenance, session.profile?.harness ? harnessKey(session.profile.harness) : undefined);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
reasons.push("controller_evidence_invalid");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (context.db.all("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'run_controllers'").length) {
|
|
58
|
+
controllers(context.db.all("SELECT controller_id, project_id, run_id, state_json FROM run_controllers WHERE project_id = ? AND run_id = ?", [projectId, runId]), home, "current");
|
|
59
|
+
}
|
|
60
|
+
const imports = path.join(home, "snapshot-imports");
|
|
61
|
+
if (fs.existsSync(imports))
|
|
62
|
+
for (const file of fs.readdirSync(imports).filter(name => name.endsWith(".json")).sort()) {
|
|
63
|
+
try {
|
|
64
|
+
const location = path.join(imports, file);
|
|
65
|
+
if (!inside(home, fs.realpathSync(location))) {
|
|
66
|
+
reasons.push("lineage_outside_owned_home");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const archive = JSON.parse(fs.readFileSync(location, "utf8"));
|
|
70
|
+
if (archive.schema_id !== "dd-flow/snapshot-runtime-lineage@1" || archive.run_id !== runId)
|
|
71
|
+
continue;
|
|
72
|
+
if (typeof archive.source_runtime !== "string" || !path.isAbsolute(archive.source_runtime)) {
|
|
73
|
+
reasons.push("lineage_source_invalid");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
controllers(archive.run_tables?.run_controllers ?? [], archive.source_runtime, "inherited");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
reasons.push("lineage_evidence_invalid");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Native transcript adapters need no managed controller journal.
|
|
83
|
+
for (const session of sessions)
|
|
84
|
+
if (session.transcript_path && ![...sources.values()].some(s => s.session.harness_id === session.harness && s.session.session_id === (session.provider_session_id ?? session.session_id))) {
|
|
85
|
+
add(`transcript:${session.harness}:${session.provider_session_id ?? session.session_id}`, session.provider_session_id ?? session.session_id, session.transcript_path, home, "current", session.harness);
|
|
86
|
+
}
|
|
87
|
+
const reducer = new ToolObservations();
|
|
88
|
+
for (const reason of reasons)
|
|
89
|
+
reducer.gaps.add(reason);
|
|
90
|
+
for (const source of sources.values()) {
|
|
91
|
+
if (source.status !== "available") {
|
|
92
|
+
reducer.gaps.add(source.reason ?? "journal_unavailable");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
replayToolJournal(path.join(home, source.path), { harness: source.session.harness_id, rootSessionId: source.session.session_id }, reducer);
|
|
96
|
+
}
|
|
97
|
+
const expected = [...new Map(sessions.map(s => { const session = { harness_id: s.harness, session_id: s.provider_session_id ?? s.session_id }; return [JSON.stringify(session), session]; })).values()];
|
|
98
|
+
const expectedIds = new Set(expected.map(s => JSON.stringify(s)));
|
|
99
|
+
for (const [key, event] of reducer.calls)
|
|
100
|
+
if (!expectedIds.has(JSON.stringify(event.session))) {
|
|
101
|
+
reducer.calls.delete(key);
|
|
102
|
+
reducer.gaps.add("tool_session_not_bound_to_run");
|
|
103
|
+
}
|
|
104
|
+
const summary = reducer.summary();
|
|
105
|
+
const observed = new Set(summary.sessions.map(s => JSON.stringify(s.session)));
|
|
106
|
+
const missing = expected.filter(s => !observed.has(JSON.stringify(s)));
|
|
107
|
+
const completeness = missing.length || summary.completeness !== "complete" ? "partial" : "complete";
|
|
108
|
+
return { schema_id: "dd-flow/run-observations@1", home, sources: [...sources.values()], tools: { ...summary,
|
|
109
|
+
completeness, status: observed.size === 0 ? "unavailable" : completeness === "complete" ? "measured" : "partial",
|
|
110
|
+
observed_sessions: expected.length - missing.length, expected_sessions: expected.length, unavailable_sessions: missing.map(s => s.session_id), missing_sessions: missing,
|
|
111
|
+
reasons: [...new Set([...summary.reasons, ...(missing.length ? ["sessions_without_tool_observations"] : [])])] } };
|
|
112
|
+
}
|
package/dist/services/runs.js
CHANGED
|
@@ -587,17 +587,6 @@ export function attachFlowRunStage(context, input) {
|
|
|
587
587
|
export function withStageSettlement(context, operation) {
|
|
588
588
|
return context.db.writeTransaction(operation);
|
|
589
589
|
}
|
|
590
|
-
/** Capture accepted inputs before asynchronous validation; compare under the
|
|
591
|
-
* settlement writer reservation instead of accepting a stale read snapshot. */
|
|
592
|
-
export function stageInputGuard(context, projectId, runId, readFiles) {
|
|
593
|
-
const read = () => JSON.stringify({
|
|
594
|
-
works: context.db.all("SELECT work_id, parent_work_id, task, status, result, payload_json, depends_on_json FROM works WHERE project_id = ? AND run_id = ? ORDER BY work_id", [projectId, runId]),
|
|
595
|
-
files: readFiles()
|
|
596
|
-
});
|
|
597
|
-
const accepted = read();
|
|
598
|
-
return () => { if (read() !== accepted)
|
|
599
|
-
throw new AppError("stage_inputs_changed", "Accepted stage inputs changed during validation; settle only newly validated inputs", 1, { run_id: runId, effect: "no_effect" }); };
|
|
600
|
-
}
|
|
601
590
|
export function completeFlowRunStage(context, input) {
|
|
602
591
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
603
592
|
const run = resolveRun(context, project.id, input.runId);
|
|
@@ -620,6 +609,7 @@ export function completeFlowRunStage(context, input) {
|
|
|
620
609
|
...(input.data ? { data: input.data } : {}),
|
|
621
610
|
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
|
|
622
611
|
...(input.report ? { report: input.report } : {}),
|
|
612
|
+
...(input.usage ? { usage: input.usage } : {}),
|
|
623
613
|
...(input.aliases && input.aliases.length > 0 ? { artifact_aliases: input.aliases } : {}),
|
|
624
614
|
updated_at: now,
|
|
625
615
|
...(status === "done" || status === "waiting_for_user" || status === "blocked" || status === "failed" || status === "skipped"
|
|
@@ -94,7 +94,7 @@ export function validateSchema(options) {
|
|
|
94
94
|
throw new AppError("usage", "--schema must be a schema name such as mb-upgrade-review-data", 2);
|
|
95
95
|
}
|
|
96
96
|
const filePath = path.resolve(options.file);
|
|
97
|
-
const data = readJson(filePath, "input file");
|
|
97
|
+
const data = Object.hasOwn(options, "data") ? options.data : readJson(filePath, "input file");
|
|
98
98
|
const schemaResolution = resolveSchema(options);
|
|
99
99
|
const schema = readJson(schemaResolution.path, "schema");
|
|
100
100
|
// Ajv's default export only carries the draft-07 meta-schema. New stage
|
|
@@ -8,7 +8,7 @@ import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from
|
|
|
8
8
|
import { attachFlowRunStage, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
10
|
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
|
-
import { usageForRun } from "./usage.js";
|
|
11
|
+
import { recalculateRunUsage, usageForRun } from "./usage.js";
|
|
12
12
|
import { boundCanonicalPlan } from "./plan-runtime.js";
|
|
13
13
|
import { AppError } from "../shared/errors.js";
|
|
14
14
|
import { planJsonPath, resolveProjectRoot } from "../storage/paths.js";
|
|
@@ -19,6 +19,16 @@ import { registerProtocol } from "./protocols.js";
|
|
|
19
19
|
import { previewNextEntityId } from "./ids.js";
|
|
20
20
|
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
21
21
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
22
|
+
function stageUsageProjection(context, view, stage, startedAt) {
|
|
23
|
+
if (!startedAt)
|
|
24
|
+
return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
|
|
25
|
+
const value = recalculateRunUsage(context, { projectId: view.run.project_id, runId: view.run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
|
|
26
|
+
const totals = value.totals, coverage = value.coverage;
|
|
27
|
+
const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
|
|
28
|
+
const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
|
|
29
|
+
const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
|
|
30
|
+
return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
|
|
31
|
+
}
|
|
22
32
|
const stagePromptSections = [
|
|
23
33
|
"stage_identity",
|
|
24
34
|
"authoritative_runtime_facts",
|
|
@@ -293,6 +303,7 @@ export function finishStage(context, input) {
|
|
|
293
303
|
const planFinish = input.stage === "plan"
|
|
294
304
|
? preparePlanFinish(context, projectRoot, view, stageRoot)
|
|
295
305
|
: undefined;
|
|
306
|
+
const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
|
|
296
307
|
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
297
308
|
const dataPath = path.join(stageRoot, "stage-report.json");
|
|
298
309
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
@@ -313,7 +324,8 @@ export function finishStage(context, input) {
|
|
|
313
324
|
stageReport: htmlPath,
|
|
314
325
|
data: dataPath,
|
|
315
326
|
dataSchemaId: String(report.schema_id),
|
|
316
|
-
report: reportPath
|
|
327
|
+
report: reportPath,
|
|
328
|
+
usage: stageUsage
|
|
317
329
|
});
|
|
318
330
|
finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
|
|
319
331
|
appendAudit(context, {
|
package/dist/services/usage.js
CHANGED
|
@@ -4,6 +4,8 @@ import { parseJsonObject } from "../shared/json.js";
|
|
|
4
4
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
5
5
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
6
6
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
7
|
+
import { runObservations } from "./run-observations.js";
|
|
8
|
+
import { ToolObservations, codexToolObservation } from "../harness-runtime/lib/tool-observations.mjs";
|
|
7
9
|
export function ingestZcodeUsage(context, input) {
|
|
8
10
|
return ingestHarnessUsage(context, input, { harness: "zcode-acp", label: "ZCode", idPrefix: "ZUSG", sourceKind: "zcode_session_usage_v1" });
|
|
9
11
|
}
|
|
@@ -193,6 +195,8 @@ export function recalculateRunUsage(context, input) {
|
|
|
193
195
|
throw error;
|
|
194
196
|
}
|
|
195
197
|
}
|
|
198
|
+
const observations = persistRunProjection ? runObservations(context, input.projectId, input.runId) : null;
|
|
199
|
+
const legacyTools = toolCallsForLinks(context, links, accountingAt);
|
|
196
200
|
return {
|
|
197
201
|
ok: true,
|
|
198
202
|
schema_id: "dd-flow/run-usage@2",
|
|
@@ -207,7 +211,8 @@ export function recalculateRunUsage(context, input) {
|
|
|
207
211
|
stopped: publicSessionIdentities(context, input.projectId, settledSessions.map((session) => session.session_id)),
|
|
208
212
|
unsettled: publicSessionIdentities(context, input.projectId, unsettledSessions)
|
|
209
213
|
},
|
|
210
|
-
tool_calls:
|
|
214
|
+
tool_calls: observations?.tools.observed_sessions ? observations.tools : legacyTools,
|
|
215
|
+
...(observations ? { observations, legacy_tool_calls: legacyTools } : {}),
|
|
211
216
|
coverage: rows.reduce((coverage, row) => ({ ...coverage, [row.status]: (coverage[row.status] ?? 0) + 1 }), {})
|
|
212
217
|
};
|
|
213
218
|
}
|
|
@@ -250,6 +255,7 @@ function publicSessionIdentities(context, projectId, storageIds) {
|
|
|
250
255
|
}
|
|
251
256
|
function toolCallsForLinks(context, links, observedAt) {
|
|
252
257
|
const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
|
|
258
|
+
let incomplete = false;
|
|
253
259
|
const sessions = new Map();
|
|
254
260
|
for (const link of links)
|
|
255
261
|
sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
|
|
@@ -262,13 +268,15 @@ function toolCallsForLinks(context, links, observedAt) {
|
|
|
262
268
|
result.unavailable_sessions.push(sessionId);
|
|
263
269
|
continue;
|
|
264
270
|
}
|
|
271
|
+
if ("completeness" in parsed && parsed.completeness !== "complete")
|
|
272
|
+
incomplete = true;
|
|
265
273
|
result.observed_sessions += 1;
|
|
266
274
|
result.total += parsed.total;
|
|
267
275
|
result.failures += parsed.failures;
|
|
268
276
|
for (const [name, count] of Object.entries(parsed.by_tool))
|
|
269
277
|
result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
|
|
270
278
|
}
|
|
271
|
-
result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
|
|
279
|
+
result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length || incomplete ? "partial" : "measured";
|
|
272
280
|
return result;
|
|
273
281
|
}
|
|
274
282
|
function isExternalHarness(harness) { return harness === "droid-cli" || harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
|
|
@@ -311,9 +319,7 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
|
|
|
311
319
|
return null;
|
|
312
320
|
try {
|
|
313
321
|
let sessionId;
|
|
314
|
-
|
|
315
|
-
let failures = 0;
|
|
316
|
-
const byTool = {};
|
|
322
|
+
const tools = new ToolObservations();
|
|
317
323
|
for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
|
|
318
324
|
if (!line.trim())
|
|
319
325
|
continue;
|
|
@@ -322,6 +328,7 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
|
|
|
322
328
|
event = JSON.parse(line);
|
|
323
329
|
}
|
|
324
330
|
catch {
|
|
331
|
+
tools.gaps.add("journal_invalid_record");
|
|
325
332
|
continue;
|
|
326
333
|
}
|
|
327
334
|
const payload = object(event.payload);
|
|
@@ -330,20 +337,13 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
|
|
|
330
337
|
if (event.type !== "response_item" || !payload)
|
|
331
338
|
continue;
|
|
332
339
|
const eventAt = stringValue(event.timestamp);
|
|
333
|
-
if (eventAt && !windows.some((window) => eventInsideWindow(eventAt, window, observedAt)))
|
|
340
|
+
if (eventAt && windows.length && !windows.some((window) => eventInsideWindow(eventAt, window, observedAt)))
|
|
334
341
|
continue;
|
|
335
|
-
|
|
336
|
-
if (type === "function_call" || type === "custom_tool_call") {
|
|
337
|
-
const name = stringValue(payload.name) ?? "unknown";
|
|
338
|
-
byTool[name] = (byTool[name] ?? 0) + 1;
|
|
339
|
-
total += 1;
|
|
340
|
-
}
|
|
341
|
-
if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
|
|
342
|
-
failures += 1;
|
|
342
|
+
tools.add(codexToolObservation(event, expectedSessionId));
|
|
343
343
|
}
|
|
344
344
|
if (sessionId && sessionId !== expectedSessionId)
|
|
345
345
|
return null;
|
|
346
|
-
return
|
|
346
|
+
return tools.summary();
|
|
347
347
|
}
|
|
348
348
|
catch {
|
|
349
349
|
return null;
|
|
@@ -355,37 +355,6 @@ function eventInsideWindow(eventAt, window, observedAt) {
|
|
|
355
355
|
const end = Date.parse(window.completed_at ?? observedAt);
|
|
356
356
|
return Number.isFinite(at) && (!Number.isFinite(start) || at >= start) && (!Number.isFinite(end) || at <= end);
|
|
357
357
|
}
|
|
358
|
-
function outputIndicatesFailure(payload) {
|
|
359
|
-
if (payload.error === true || typeof payload.error === "string")
|
|
360
|
-
return true;
|
|
361
|
-
return containsFailure(payload.output);
|
|
362
|
-
}
|
|
363
|
-
function containsFailure(value) {
|
|
364
|
-
if (Array.isArray(value))
|
|
365
|
-
return value.some(containsFailure);
|
|
366
|
-
if (typeof value === "string") {
|
|
367
|
-
if (/(?:^|\n)exit=(?:[1-9]\d*)\b|Process exited with code [1-9]\d*/.test(value))
|
|
368
|
-
return true;
|
|
369
|
-
const trimmed = value.trim();
|
|
370
|
-
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
371
|
-
try {
|
|
372
|
-
return containsFailure(JSON.parse(trimmed));
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return false;
|
|
379
|
-
}
|
|
380
|
-
const item = object(value);
|
|
381
|
-
if (!item)
|
|
382
|
-
return false;
|
|
383
|
-
if (item.ok === false || item.is_error === true || item.isError === true || item.error === true || typeof item.error === "string")
|
|
384
|
-
return true;
|
|
385
|
-
if (typeof item.exit_code === "number" && item.exit_code !== 0)
|
|
386
|
-
return true;
|
|
387
|
-
return Object.values(item).some(containsFailure);
|
|
388
|
-
}
|
|
389
358
|
/** RUN accounting belongs to the physical provider Session, not to every Work link. */
|
|
390
359
|
function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
|
|
391
360
|
const sessions = new Map();
|
|
@@ -586,62 +555,26 @@ function usageReport(context, input, refresh) {
|
|
|
586
555
|
}
|
|
587
556
|
function toolCallsForSessions(sessions) {
|
|
588
557
|
const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
|
|
558
|
+
let incomplete = false;
|
|
589
559
|
for (const session of sessions) {
|
|
590
560
|
const parsed = readToolCalls(session.transcript_path, nativeSessionId(session));
|
|
591
561
|
if (!parsed) {
|
|
592
562
|
result.unavailable_sessions.push(session.session_id);
|
|
593
563
|
continue;
|
|
594
564
|
}
|
|
565
|
+
if (parsed.completeness !== "complete")
|
|
566
|
+
incomplete = true;
|
|
595
567
|
result.observed_sessions += 1;
|
|
596
568
|
result.total += parsed.total;
|
|
597
569
|
result.failures += parsed.failures;
|
|
598
570
|
for (const [name, count] of Object.entries(parsed.by_tool))
|
|
599
571
|
result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
|
|
600
572
|
}
|
|
601
|
-
result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length
|
|
573
|
+
result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length || incomplete ? "partial" : "measured";
|
|
602
574
|
return result;
|
|
603
575
|
}
|
|
604
576
|
function readToolCalls(transcriptPath, expectedSessionId) {
|
|
605
|
-
|
|
606
|
-
return null;
|
|
607
|
-
try {
|
|
608
|
-
let sessionId;
|
|
609
|
-
const byTool = {};
|
|
610
|
-
let total = 0;
|
|
611
|
-
let failures = 0;
|
|
612
|
-
for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
|
|
613
|
-
if (!line.trim())
|
|
614
|
-
continue;
|
|
615
|
-
let event;
|
|
616
|
-
try {
|
|
617
|
-
event = JSON.parse(line);
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
const payload = object(event.payload);
|
|
623
|
-
if (!payload)
|
|
624
|
-
continue;
|
|
625
|
-
if (event.type === "session_meta")
|
|
626
|
-
sessionId = stringValue(payload.id) ?? sessionId;
|
|
627
|
-
if (event.type !== "response_item")
|
|
628
|
-
continue;
|
|
629
|
-
const type = stringValue(payload.type);
|
|
630
|
-
if (type === "function_call" || type === "custom_tool_call") {
|
|
631
|
-
const name = stringValue(payload.name) ?? "unknown";
|
|
632
|
-
byTool[name] = (byTool[name] ?? 0) + 1;
|
|
633
|
-
total += 1;
|
|
634
|
-
}
|
|
635
|
-
if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
|
|
636
|
-
failures += 1;
|
|
637
|
-
}
|
|
638
|
-
if (sessionId && sessionId !== expectedSessionId)
|
|
639
|
-
return null;
|
|
640
|
-
return { total, failures, by_tool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => left.localeCompare(right))) };
|
|
641
|
-
}
|
|
642
|
-
catch {
|
|
643
|
-
return null;
|
|
644
|
-
}
|
|
577
|
+
return readToolCallsForWindows(transcriptPath, expectedSessionId, [], new Date().toISOString());
|
|
645
578
|
}
|
|
646
579
|
function segmentUsage(context, session, segment) {
|
|
647
580
|
if (!session)
|