@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.11
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 +77 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +3 -3
- package/dist/cli/run-cli.js +127 -8
- package/dist/runtime/context.js +3 -1
- package/dist/schemas/code-review-result.schema.json +1 -1
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +1 -1
- package/dist/schemas/harness-config.schema.json +23 -0
- package/dist/schemas/plan-review-decision.schema.json +1 -1
- package/dist/schemas/vnext-protocol-plan.schema.json +1 -1
- package/dist/services/cleanup.js +18 -8
- package/dist/services/code-checks.js +194 -44
- package/dist/services/engines.js +4 -4
- package/dist/services/eval-snapshots.js +10 -5
- package/dist/services/harness-config.js +66 -0
- package/dist/services/hooks.js +62 -28
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +8 -2
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/prompts.js +4 -2
- package/dist/services/run-engine-bindings.js +19 -61
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +71 -9
- package/dist/services/schema-validation.js +11 -11
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-lifecycle.js +15 -8
- package/dist/services/stage-pause.js +35 -20
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +82 -41
- package/dist/services/vnext-code.js +98 -34
- package/dist/services/vnext-fanout.js +5 -12
- package/dist/services/vnext-merge.js +144 -65
- package/dist/services/vnext-plan-review.js +50 -35
- package/dist/services/vnext-plan.js +69 -21
- package/dist/services/vnext-protocolize.js +6 -6
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +150 -40
- package/dist/storage/database.js +128 -2
- package/package.json +1 -1
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
4
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
4
5
|
export function refreshRunSessionProjection(context, projectId, runId) {
|
|
5
6
|
const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
6
7
|
if (!run)
|
|
@@ -9,7 +10,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
9
10
|
const sessionRows = context.db.all(`SELECT session_id, harness, provider_session_id, parent_session_id, role, session_kind, worker_id, current_stage, status,
|
|
10
11
|
created_at, updated_at, stopped_at, coverage_units_json
|
|
11
12
|
FROM sessions WHERE project_id = ? AND run_id = ? ORDER BY created_at, session_id`, [projectId, runId]);
|
|
12
|
-
const
|
|
13
|
+
const sessionIdentities = new Map(sessionRows.map((row) => [row.session_id, publicSessionIdentity(row)]));
|
|
14
|
+
const sessions = sessionRows.map((row) => sessionProjection(row, sessionIdentities));
|
|
13
15
|
const workRows = context.db.all("SELECT work_id, parent_work_id, status, depends_on_json, launch_policy, started_at, completed_at FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]);
|
|
14
16
|
const works = workRows.map((work) => ({ work_id: work.work_id, parent_work_id: work.parent_work_id, status: work.status, depends_on: parseStringArray(work.depends_on_json), launch_policy: work.launch_policy, started_at: work.started_at, completed_at: work.completed_at }));
|
|
15
17
|
const rootWorkId = workRows.find((work) => work.parent_work_id === null)?.work_id ?? null;
|
|
@@ -123,10 +125,12 @@ function readPlanId(planPath) {
|
|
|
123
125
|
return "unknown-plan";
|
|
124
126
|
}
|
|
125
127
|
function workerProjection(context, projectId, runId) {
|
|
126
|
-
return Object.fromEntries(context.db.all(
|
|
128
|
+
return Object.fromEntries(context.db.all(`SELECT j.job_id, j.plan_item_id, j.status, j.worker_session_id, s.harness, s.provider_session_id
|
|
129
|
+
FROM flow_jobs j LEFT JOIN sessions s ON s.project_id = j.project_id AND s.session_id = j.worker_session_id
|
|
130
|
+
WHERE j.project_id = ? AND j.run_id = ? ORDER BY j.job_id`, [projectId, runId]).map((job) => [job.job_id, {
|
|
127
131
|
units: [job.plan_item_id],
|
|
128
132
|
status: { pending: "registered", done: "completed" }[job.status] ?? job.status,
|
|
129
|
-
...(job.worker_session_id ? { session_id: job.worker_session_id } : {})
|
|
133
|
+
...(job.worker_session_id && job.harness ? { session: publicSessionIdentity({ harness: job.harness, provider_session_id: job.provider_session_id, session_id: job.worker_session_id }) } : {})
|
|
130
134
|
}]));
|
|
131
135
|
}
|
|
132
136
|
function parseStringArray(value) {
|
|
@@ -138,12 +142,10 @@ function parseStringArray(value) {
|
|
|
138
142
|
return [];
|
|
139
143
|
}
|
|
140
144
|
}
|
|
141
|
-
function sessionProjection(row) {
|
|
145
|
+
function sessionProjection(row, identities) {
|
|
142
146
|
return {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
provider_session_id: row.provider_session_id,
|
|
146
|
-
parent_session_id: row.parent_session_id,
|
|
147
|
+
session: publicSessionIdentity(row),
|
|
148
|
+
...(row.parent_session_id ? { parent_session: identities.get(row.parent_session_id) ?? null } : {}),
|
|
147
149
|
role: row.role,
|
|
148
150
|
session_kind: row.session_kind,
|
|
149
151
|
work_id: row.worker_id,
|
package/dist/services/runs.js
CHANGED
|
@@ -15,6 +15,8 @@ import { refreshRunSessionProjection } from "./run-projection.js";
|
|
|
15
15
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
16
|
import { bindCurrentEngineToRun } from "./engines.js";
|
|
17
17
|
import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
|
|
18
|
+
import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
|
|
19
|
+
import { publicSessionIdentity } from "./session-identity.js";
|
|
18
20
|
const runSchemaId = "dd-flow/flow-run@3";
|
|
19
21
|
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
20
22
|
const runIdType = "RUN";
|
|
@@ -124,7 +126,7 @@ export function startFlowRun(context, input) {
|
|
|
124
126
|
};
|
|
125
127
|
ensureDir(path.dirname(runtimePath));
|
|
126
128
|
ensureDir(runHome);
|
|
127
|
-
bindCurrentEngineToRun(context, { projectRoot, runId, runHome });
|
|
129
|
+
bindCurrentEngineToRun(context, { projectRoot, runId, runRoot: runHome });
|
|
128
130
|
const persistedIndex = persistedVnextIndex(index);
|
|
129
131
|
writeJsonFile(runtimePath, runtimeSnapshotForIndex(persistedIndex, 1));
|
|
130
132
|
context.db.run(`INSERT INTO runs
|
|
@@ -290,6 +292,22 @@ export function setFlowRunConfig(context, input) {
|
|
|
290
292
|
appendRunTimeline(runArtifactRoot(run), { at: now, type: "run_config_set", run_id: run.id, key: input.key, value: mode, source, reason });
|
|
291
293
|
return { ok: true, run_id: run.id, settings: runSettings(index), idempotent: false };
|
|
292
294
|
}
|
|
295
|
+
/** Freeze an automatic review request once from already accepted policy or PLAN facts. */
|
|
296
|
+
export function freezeFlowRunReviewMode(context, input) {
|
|
297
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
298
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
299
|
+
const index = authoritativeIndex(run);
|
|
300
|
+
const settings = runSettings(index);
|
|
301
|
+
const key = input.review === "plan" ? "plan_review" : "code_review";
|
|
302
|
+
const current = settings[key];
|
|
303
|
+
if (current.mode !== "auto")
|
|
304
|
+
return;
|
|
305
|
+
const now = context.now();
|
|
306
|
+
index.settings = { ...settings, [key]: { mode: input.mode, source: input.source, reason: requiredPlain(input.reason, "reason"), updated_at: now } };
|
|
307
|
+
index.updated_at = now;
|
|
308
|
+
persistRunState(context, project, run, index);
|
|
309
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.review_mode_frozen", payload: { run_id: run.id, review: input.review, mode: input.mode, source: input.source, reason: input.reason } });
|
|
310
|
+
}
|
|
293
311
|
export function getFlowRunFlagsStatus(context, input) {
|
|
294
312
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
295
313
|
const run = resolveRun(context, project.id, input.runId);
|
|
@@ -450,6 +468,7 @@ export function attachFlowRunStage(context, input) {
|
|
|
450
468
|
const dir = requiredStageDir(input.dir);
|
|
451
469
|
const status = parseStageStatus(input.status);
|
|
452
470
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
471
|
+
assertVnextStageStart(run, index, stage, status, existing);
|
|
453
472
|
if (status === "running" && existing) {
|
|
454
473
|
archiveExistingStageAttempt(runArtifactRoot(run), dir);
|
|
455
474
|
}
|
|
@@ -674,8 +693,13 @@ export function completeFlowRun(context, input) {
|
|
|
674
693
|
const index = authoritativeIndex(run);
|
|
675
694
|
const now = context.now();
|
|
676
695
|
const status = parseRunStatus(input.status);
|
|
677
|
-
if (!['done', '
|
|
678
|
-
throw new AppError('validation', 'completeFlowRun accepts
|
|
696
|
+
if (!['done', 'cancelled', 'failed'].includes(status)) {
|
|
697
|
+
throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
|
|
698
|
+
}
|
|
699
|
+
if (!input.manualOverrideReason) {
|
|
700
|
+
const activeWork = context.db.get("SELECT work_id, status FROM works WHERE project_id = ? AND run_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [project.id, run.id]);
|
|
701
|
+
if (activeWork)
|
|
702
|
+
throw new AppError("active_work", "RUN cannot complete while Work remains active", 2, { run_id: run.id, work_id: activeWork.work_id, status: activeWork.status });
|
|
679
703
|
}
|
|
680
704
|
if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
|
|
681
705
|
closeOpenStagesForOverride(index, status, now);
|
|
@@ -685,7 +709,7 @@ export function completeFlowRun(context, input) {
|
|
|
685
709
|
index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
|
|
686
710
|
index.next_action = input.nextAction ?? null;
|
|
687
711
|
index.updated_at = now;
|
|
688
|
-
if (["done", "
|
|
712
|
+
if (["done", "cancelled", "failed"].includes(status)) {
|
|
689
713
|
index.completed_at = now;
|
|
690
714
|
index.finished_at = now;
|
|
691
715
|
index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
|
|
@@ -733,7 +757,7 @@ export function advanceFlowRun(context, input) {
|
|
|
733
757
|
function closeOpenStagesForOverride(index, status, now) {
|
|
734
758
|
const stageStatus = status === "cancelled" ? "skipped" : "failed";
|
|
735
759
|
for (const stage of index.stage_runs) {
|
|
736
|
-
if (stage.status !== "running" && stage.status !== "pending")
|
|
760
|
+
if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
|
|
737
761
|
continue;
|
|
738
762
|
stage.status = stageStatus;
|
|
739
763
|
stage.updated_at = now;
|
|
@@ -755,7 +779,7 @@ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
|
|
|
755
779
|
context.db.run(`UPDATE works
|
|
756
780
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
757
781
|
WHERE project_id = ? AND run_id = ?
|
|
758
|
-
AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
|
|
782
|
+
AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
|
|
759
783
|
context.db.run(`UPDATE work_sessions
|
|
760
784
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
761
785
|
WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
|
|
@@ -881,7 +905,21 @@ export function getFlowRunSessions(context, input) {
|
|
|
881
905
|
s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
|
|
882
906
|
s.agent_type, s.transcript_path
|
|
883
907
|
ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
|
|
884
|
-
|
|
908
|
+
const identities = new Map(sessions.map((row) => [String(row.session_id), publicSessionIdentity({
|
|
909
|
+
harness: String(row.harness),
|
|
910
|
+
provider_session_id: typeof row.provider_session_id === "string" ? row.provider_session_id : null,
|
|
911
|
+
session_id: String(row.session_id)
|
|
912
|
+
})]));
|
|
913
|
+
return {
|
|
914
|
+
ok: true,
|
|
915
|
+
schema_id: "dd-flow/run-sessions@4",
|
|
916
|
+
run_id: run.id,
|
|
917
|
+
sessions: sessions.map(({ session_id, harness: _harness, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
|
|
918
|
+
...row,
|
|
919
|
+
session: identities.get(String(session_id)) ?? null,
|
|
920
|
+
...(typeof parent_session_id === "string" ? { parent_session: identities.get(parent_session_id) ?? null } : {})
|
|
921
|
+
}))
|
|
922
|
+
};
|
|
885
923
|
}
|
|
886
924
|
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
887
925
|
const run = requireRunById(context, projectId, runId);
|
|
@@ -1133,6 +1171,28 @@ function nextAttempt(existing) {
|
|
|
1133
1171
|
const number = Number(existing.attempt.replace("try-", ""));
|
|
1134
1172
|
return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
|
|
1135
1173
|
}
|
|
1174
|
+
function assertVnextStageStart(run, index, stage, status, existing) {
|
|
1175
|
+
if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
|
|
1176
|
+
return;
|
|
1177
|
+
if (!vnextStages.some((candidate) => candidate.id === stage))
|
|
1178
|
+
return;
|
|
1179
|
+
if (existing) {
|
|
1180
|
+
if (existing.status === "running")
|
|
1181
|
+
throw new AppError("stage_already_running", "The same stage attempt is already running; continue it instead of starting another attempt", 2, { run_id: run.id, stage, attempt: existing.attempt ?? null });
|
|
1182
|
+
throw new AppError("illegal_stage_transition", "A completed or paused vNext stage cannot be started as a new implicit attempt", 2, { run_id: run.id, stage, status: existing.status });
|
|
1183
|
+
}
|
|
1184
|
+
const completed = index.stage_runs.filter((candidate) => candidate.status === "done" || candidate.status === "skipped").sort((left, right) => right.order - left.order)[0];
|
|
1185
|
+
if (!completed) {
|
|
1186
|
+
if (stage === "specify")
|
|
1187
|
+
return;
|
|
1188
|
+
throw new AppError("illegal_stage_transition", "The vNext flow must start with SPECIFY", 2, { run_id: run.id, stage });
|
|
1189
|
+
}
|
|
1190
|
+
if (!isLegalVnextTransition(completed.stage, stage))
|
|
1191
|
+
throw new AppError("illegal_stage_transition", "The requested stage is not a legal successor of the last accepted stage", 2, { run_id: run.id, from: completed.stage, to: stage });
|
|
1192
|
+
const unfinished = index.stage_runs.find((candidate) => !["done", "skipped"].includes(candidate.status));
|
|
1193
|
+
if (unfinished)
|
|
1194
|
+
throw new AppError("illegal_stage_transition", "Another stage is still active", 2, { run_id: run.id, active_stage: unfinished.stage, active_status: unfinished.status, to: stage });
|
|
1195
|
+
}
|
|
1136
1196
|
function upsertStage(index, stageRun) {
|
|
1137
1197
|
const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
|
|
1138
1198
|
if (position === -1) {
|
|
@@ -1201,7 +1261,7 @@ function flowRunSummary(run) {
|
|
|
1201
1261
|
runtime_path: run.runtime_path,
|
|
1202
1262
|
run_dir: run.run_dir,
|
|
1203
1263
|
run_index_path: run.run_index_path,
|
|
1204
|
-
|
|
1264
|
+
run_root: run.run_root,
|
|
1205
1265
|
layout_version: run.layout_version ?? null,
|
|
1206
1266
|
artifact_root_kind: run.artifact_root_kind ?? null,
|
|
1207
1267
|
created_at: run.created_at,
|
|
@@ -1296,7 +1356,9 @@ function writeJsonFile(file, value) {
|
|
|
1296
1356
|
fs.renameSync(tmpFile, file);
|
|
1297
1357
|
}
|
|
1298
1358
|
function runArtifactRoot(run) {
|
|
1299
|
-
|
|
1359
|
+
if (!run.run_root)
|
|
1360
|
+
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id });
|
|
1361
|
+
return run.run_root;
|
|
1300
1362
|
}
|
|
1301
1363
|
export function gitFacts(workspaceRoot) {
|
|
1302
1364
|
const read = (args) => {
|
|
@@ -6,7 +6,7 @@ import { Ajv } from "ajv/dist/ajv.js";
|
|
|
6
6
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
7
7
|
import { normalizeFlowContract } from "../domain/flow-contract.js";
|
|
8
8
|
import { AppError } from "../shared/errors.js";
|
|
9
|
-
import {
|
|
9
|
+
import { readRunEngineBinding } from "./run-engine-bindings.js";
|
|
10
10
|
export function captureMemoryBankBaseline(input) {
|
|
11
11
|
const projectRoot = path.resolve(input.projectRoot);
|
|
12
12
|
const paths = normalizeMemoryBankPaths(projectRoot, input.paths);
|
|
@@ -117,8 +117,8 @@ export function validateSchema(options) {
|
|
|
117
117
|
}
|
|
118
118
|
const valid = validate(data);
|
|
119
119
|
const errors = valid ? [] : (validate.errors ?? []).map(formatAjvError);
|
|
120
|
-
const
|
|
121
|
-
const allErrors = [...errors, ...
|
|
120
|
+
const contractErrors = valid ? validateContractInvariants(options.schemaName, data) : [];
|
|
121
|
+
const allErrors = [...errors, ...contractErrors];
|
|
122
122
|
if (allErrors.length > 0) {
|
|
123
123
|
throw new AppError("schema_validation", `${path.basename(filePath)} does not match schema`, 2, {
|
|
124
124
|
schema: schemaResolution,
|
|
@@ -181,7 +181,7 @@ const historicalSchemaRegistry = {
|
|
|
181
181
|
}]
|
|
182
182
|
};
|
|
183
183
|
function resolveRunBoundSchema(options) {
|
|
184
|
-
if (!options.
|
|
184
|
+
if (!options.runRoot || options.schemaDir)
|
|
185
185
|
return null;
|
|
186
186
|
const data = readJson(path.resolve(options.file), "input file");
|
|
187
187
|
const root = asRecord(data);
|
|
@@ -190,13 +190,13 @@ function resolveRunBoundSchema(options) {
|
|
|
190
190
|
const runId = options.runId ?? inferredRunId;
|
|
191
191
|
if (typeof runId !== "string")
|
|
192
192
|
return null;
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
return null;
|
|
197
|
-
const binding = readRunEngineBinding(located.binding_path, { allowMissing: true });
|
|
193
|
+
const runRoot = path.resolve(options.runRoot);
|
|
194
|
+
const bindingPath = path.join(runRoot, "engine-binding.json");
|
|
195
|
+
const binding = readRunEngineBinding(bindingPath);
|
|
198
196
|
if (!binding)
|
|
199
|
-
|
|
197
|
+
throw new AppError("run_engine_binding_missing", "RUN engine binding is missing", 1, { path: bindingPath });
|
|
198
|
+
if (binding.run_id !== runId)
|
|
199
|
+
throw new AppError("run_engine_binding_invalid", "RUN engine binding belongs to another RUN", 1, { expected_run_id: runId, actual_run_id: binding.run_id, path: bindingPath });
|
|
200
200
|
const manifestPath = path.join(binding.engine.snapshot_root, "engine.json");
|
|
201
201
|
const manifest = readJson(manifestPath, "bound engine manifest");
|
|
202
202
|
const integrity = asRecord(objectValue(manifest, "integrity"));
|
|
@@ -284,7 +284,7 @@ function formatAjvError(error) {
|
|
|
284
284
|
keyword: error.keyword
|
|
285
285
|
};
|
|
286
286
|
}
|
|
287
|
-
function
|
|
287
|
+
function validateContractInvariants(schemaName, data) {
|
|
288
288
|
const root = asRecord(data);
|
|
289
289
|
if (!root) {
|
|
290
290
|
return [];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { AppError } from "../shared/errors.js";
|
|
3
|
+
/** Internal SQLite key. Never expose this as a native harness Session ID. */
|
|
4
|
+
export function storageSessionId(identity) {
|
|
5
|
+
const harness = requirePart(identity.harness_id, "harness_id");
|
|
6
|
+
const session = requirePart(identity.session_id, "session_id");
|
|
7
|
+
return `SES-${crypto.createHash("sha256").update(harness).update("\0").update(session).digest("hex").slice(0, 24)}`;
|
|
8
|
+
}
|
|
9
|
+
export function nativeSessionIdentity(harnessId, sessionId) {
|
|
10
|
+
return { harness_id: requirePart(harnessId, "harness_id"), session_id: requirePart(sessionId, "session_id") };
|
|
11
|
+
}
|
|
12
|
+
export function publicSessionIdentity(input) {
|
|
13
|
+
return nativeSessionIdentity(input.harness, input.provider_session_id ?? input.session_id);
|
|
14
|
+
}
|
|
15
|
+
function requirePart(value, field) {
|
|
16
|
+
if (!value || !value.trim())
|
|
17
|
+
throw new AppError("session_identity_invalid", `${field} must be a non-empty opaque value`, 1, { field });
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
@@ -11,6 +11,18 @@ import { checkpointSessionUsage } from "./usage.js";
|
|
|
11
11
|
import { refreshRunSessionProjection } from "./run-projection.js";
|
|
12
12
|
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
13
13
|
import { appendFlowRunTimelineEvent } from "./runs.js";
|
|
14
|
+
import { publicSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
15
|
+
export function publicFlowSession(context, session) {
|
|
16
|
+
const identity = publicSessionIdentity(session);
|
|
17
|
+
const parent = session.parent_session_id
|
|
18
|
+
? context.db.get("SELECT harness, provider_session_id, session_id FROM sessions WHERE project_id = ? AND session_id = ?", [session.project_id, session.parent_session_id])
|
|
19
|
+
: null;
|
|
20
|
+
const publicFields = { ...session };
|
|
21
|
+
delete publicFields.session_id;
|
|
22
|
+
delete publicFields.provider_session_id;
|
|
23
|
+
delete publicFields.parent_session_id;
|
|
24
|
+
return { ...publicFields, harness_id: identity.harness_id, session_id: identity.session_id, parent_session: parent ? publicSessionIdentity(parent) : null };
|
|
25
|
+
}
|
|
14
26
|
export function recordFlowSessionObservation(context, input) {
|
|
15
27
|
const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
|
|
16
28
|
if (duplicate)
|
|
@@ -86,7 +98,7 @@ export function registerFlowSession(context, input) {
|
|
|
86
98
|
},
|
|
87
99
|
...(session.protocol_id ? { protocolId: session.protocol_id } : {})
|
|
88
100
|
});
|
|
89
|
-
return { ok: true, session };
|
|
101
|
+
return { ok: true, session: publicFlowSession(context, session) };
|
|
90
102
|
}
|
|
91
103
|
export function getFlowSessionStatus(context, input) {
|
|
92
104
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
@@ -96,7 +108,7 @@ export function getFlowSessionStatus(context, input) {
|
|
|
96
108
|
});
|
|
97
109
|
return {
|
|
98
110
|
ok: true,
|
|
99
|
-
sessions,
|
|
111
|
+
sessions: sessions.map((session) => publicFlowSession(context, session)),
|
|
100
112
|
coverage: reconcileSessionCoverage(sessions)
|
|
101
113
|
};
|
|
102
114
|
}
|
|
@@ -111,13 +123,14 @@ export function stopFlowSession(context, input) {
|
|
|
111
123
|
markSessionStopped(context, project, session, input.reason);
|
|
112
124
|
if (session.run_id)
|
|
113
125
|
refreshRunSessionProjection(context, project.id, session.run_id);
|
|
114
|
-
|
|
126
|
+
const updated = flowSessionById(context, project.id, input.sessionId);
|
|
127
|
+
return { ok: true, session: updated ? publicFlowSession(context, updated) : null };
|
|
115
128
|
}
|
|
116
129
|
export function syncFlowSessionUsage(context, input) {
|
|
117
130
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
118
131
|
const session = requireFlowSession(context, project.id, input.sessionId);
|
|
119
132
|
if (!session.run_id) {
|
|
120
|
-
return { ok: true,
|
|
133
|
+
return { ok: true, session: publicSessionIdentity(session), extraction_status: "not_observable", diagnostic: "session_not_bound_to_run" };
|
|
121
134
|
}
|
|
122
135
|
return { ok: true, snapshot: checkpointSessionUsage(context, session, { checkpoint: "manual_sync", stage: session.current_stage }) };
|
|
123
136
|
}
|
|
@@ -158,7 +171,7 @@ export function activeFlowSessionsForProject(context, projectId) {
|
|
|
158
171
|
}
|
|
159
172
|
export function flowSessionsForProject(context, projectId, filter = {}) {
|
|
160
173
|
if (filter.sessionId) {
|
|
161
|
-
return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND session_id = ? ORDER BY updated_at DESC", [projectId, filter.sessionId]);
|
|
174
|
+
return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?) ORDER BY updated_at DESC", [projectId, filter.sessionId, filter.sessionId]);
|
|
162
175
|
}
|
|
163
176
|
if (filter.workerId) {
|
|
164
177
|
return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND worker_id = ? ORDER BY updated_at DESC", [projectId, filter.workerId]);
|
|
@@ -166,10 +179,7 @@ export function flowSessionsForProject(context, projectId, filter = {}) {
|
|
|
166
179
|
return context.db.all("SELECT * FROM sessions WHERE project_id = ? ORDER BY updated_at DESC", [projectId]);
|
|
167
180
|
}
|
|
168
181
|
export function flowSessionById(context, projectId, sessionId) {
|
|
169
|
-
return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND session_id = ?", [
|
|
170
|
-
projectId,
|
|
171
|
-
sessionId
|
|
172
|
-
]);
|
|
182
|
+
return context.db.get("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?)", [projectId, sessionId, sessionId]);
|
|
173
183
|
}
|
|
174
184
|
export function updateFlowSessionContinuation(context, projectId, sessionId, actionKey) {
|
|
175
185
|
const session = flowSessionById(context, projectId, sessionId);
|
|
@@ -286,7 +296,12 @@ function normalizeFlowSessionPayload(payload) {
|
|
|
286
296
|
}
|
|
287
297
|
function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
288
298
|
const now = context.now();
|
|
289
|
-
|
|
299
|
+
// `sessions.session_id` is a private storage key. The native ID is kept
|
|
300
|
+
// unchanged in provider_session_id and is always paired with its harness in
|
|
301
|
+
// public contracts. A caller may pass a forced key only after a trusted
|
|
302
|
+
// hook has already derived it from that pair.
|
|
303
|
+
const nativeSessionId = payload.provider_session_id ?? payload.session_id ?? payload.worker_id ?? payload.protocol_id ?? crypto.randomUUID();
|
|
304
|
+
const sessionId = forcedSessionId ?? storageSessionId({ harness_id: payload.harness ?? "codex-desktop", session_id: nativeSessionId });
|
|
290
305
|
const workspacePath = payload.workspace_path ?? payload.cwd ?? project.root;
|
|
291
306
|
context.db.run(`INSERT INTO sessions
|
|
292
307
|
(session_id, project_id, harness, provider_session_id, agent_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, parent_session_id, role, aspect_id, plan_item_id, session_kind, protocol_id, worker_id, workspace_path,
|
|
@@ -334,7 +349,7 @@ function upsertFlowSession(context, project, payload, forcedSessionId) {
|
|
|
334
349
|
sessionId,
|
|
335
350
|
project.id,
|
|
336
351
|
payload.harness ?? "codex-desktop",
|
|
337
|
-
|
|
352
|
+
nativeSessionId,
|
|
338
353
|
payload.agent_id ?? null,
|
|
339
354
|
payload.provider ?? null,
|
|
340
355
|
payload.model ?? null,
|
|
@@ -53,7 +53,9 @@ function bootstrapStageRun(context, projectRoot, input) {
|
|
|
53
53
|
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
|
|
54
54
|
throw new AppError("not_found", "--intake-file must point to an existing file", 1, { intake_file: intakeFile });
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
if (!started.run.run_root)
|
|
57
|
+
throw new AppError("runtime_missing", "New RUN has no artifact root", 1, { run_id: started.run.id });
|
|
58
|
+
const intakeDir = path.join(started.run.run_root, "intake");
|
|
57
59
|
fs.mkdirSync(intakeDir, { recursive: true });
|
|
58
60
|
fs.copyFileSync(source, path.join(intakeDir, "user-request.md"));
|
|
59
61
|
}
|
|
@@ -216,7 +218,7 @@ export function startStage(context, input) {
|
|
|
216
218
|
})),
|
|
217
219
|
worker_prompt_markdown: prompt
|
|
218
220
|
});
|
|
219
|
-
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, ddFlowHome: context.ddFlowHome });
|
|
221
|
+
validateSchema({ schemaName: "stage-prompt", file: promptDataPath, projectRoot, runId: attached.run.id, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
220
222
|
atomicWrite(path.join(stageRoot, "stage-start.json"), {
|
|
221
223
|
schema_id: "dd-flow/stage-start@2",
|
|
222
224
|
run_id: attached.run.id,
|
|
@@ -273,7 +275,7 @@ export function finishStage(context, input) {
|
|
|
273
275
|
const semanticFile = resolveStageFile(input.semanticFile ?? "@stage/stage-input.json", stageRoot, runHome);
|
|
274
276
|
const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage);
|
|
275
277
|
try {
|
|
276
|
-
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
278
|
+
validateSchema({ schemaName: "stage-finish-input", file: semanticFile, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
277
279
|
const semantic = readSemanticFile(semanticFile, runHome);
|
|
278
280
|
const status = stringValue(semantic.status) ?? "done";
|
|
279
281
|
if (!["done", "waiting_for_user", "blocked", "failed"].includes(status)) {
|
|
@@ -293,7 +295,7 @@ export function finishStage(context, input) {
|
|
|
293
295
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
294
296
|
const htmlPath = path.join(stageRoot, "stage-report.html");
|
|
295
297
|
atomicWrite(dataPath, report);
|
|
296
|
-
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, ddFlowHome: context.ddFlowHome });
|
|
298
|
+
validateSchema({ schemaName: stageReportSchemaName(input.stage, planFinish), file: dataPath, projectRoot, runId, runRoot: runHome, ddFlowHome: context.ddFlowHome });
|
|
297
299
|
atomicWrite(reportPath, renderMarkdown(report));
|
|
298
300
|
atomicWrite(htmlPath, renderHtml(projectRoot, report));
|
|
299
301
|
if (view.run.subject.type === "protocol") {
|
|
@@ -410,7 +412,12 @@ function nextActionForProtocolStage(stage) {
|
|
|
410
412
|
return `continue_${stage}`;
|
|
411
413
|
}
|
|
412
414
|
function runHomePath(run) {
|
|
413
|
-
|
|
415
|
+
if (!run.run_root)
|
|
416
|
+
throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
|
|
417
|
+
return run.run_root;
|
|
418
|
+
}
|
|
419
|
+
function runStatePath(run) {
|
|
420
|
+
return path.join(runHomePath(run), "run.json");
|
|
414
421
|
}
|
|
415
422
|
export function defaultStageDir(stage, flowKind) {
|
|
416
423
|
const normalized = stage === "implementation" ? "code" : stage;
|
|
@@ -702,7 +709,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
702
709
|
if (stage === "code" || stage === "implementation") {
|
|
703
710
|
return {
|
|
704
711
|
schema_id: "dd-flow/code-stage-report@2",
|
|
705
|
-
run: { run_id: view.run.id, run_state: view.run
|
|
712
|
+
run: { run_id: view.run.id, run_state: runStatePath(view.run) },
|
|
706
713
|
stage: { name: stage, dir: path.basename(stageRoot), status },
|
|
707
714
|
project: { id: view.run.project_id, title: path.basename(view.run.project_root) },
|
|
708
715
|
subject: { id: view.run.subject.id, title: view.run.subject.id },
|
|
@@ -712,7 +719,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
712
719
|
summary: result,
|
|
713
720
|
next_action: stringValue(semantic.next_action) ?? "Proceed to readiness."
|
|
714
721
|
},
|
|
715
|
-
breadcrumbs: [{ label: "RUN", href: view.run
|
|
722
|
+
breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }],
|
|
716
723
|
implemented_goals: [{ title: `Stage ${stage}`, summary: result }],
|
|
717
724
|
acceptance_scenarios: acceptance.map((summary, index) => ({
|
|
718
725
|
id: `SCN-${stage}-${index + 1}`,
|
|
@@ -763,7 +770,7 @@ function buildStageReport(context, view, stage, status, semantic, stageRoot, lin
|
|
|
763
770
|
memory_bank_scope: "changed_files_and_links_only",
|
|
764
771
|
status: "passed"
|
|
765
772
|
},
|
|
766
|
-
breadcrumbs: [{ label: "RUN", href: view.run
|
|
773
|
+
breadcrumbs: [{ label: "RUN", href: runStatePath(view.run), status: "available" }]
|
|
767
774
|
};
|
|
768
775
|
}
|
|
769
776
|
function preparePlanFinish(context, projectRoot, view, stageRoot) {
|
|
@@ -7,7 +7,7 @@ import { resolveProjectRoot } from "../storage/paths.js";
|
|
|
7
7
|
import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, pauseFlowRunStage, resumeFlowRunStage } from "./runs.js";
|
|
10
|
-
import { refreshRunWorkProjection } from "./work-registry.js";
|
|
10
|
+
import { bindSessionForResume, refreshRunWorkProjection } from "./work-registry.js";
|
|
11
11
|
export function pauseStageForUser(context, input) {
|
|
12
12
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
13
13
|
const project = requireProjectByRoot(context, projectRoot);
|
|
@@ -25,8 +25,18 @@ export function pauseStageForUser(context, input) {
|
|
|
25
25
|
fs.mkdirSync(pauseRoot, { recursive: true });
|
|
26
26
|
fs.writeFileSync(questionPath, question);
|
|
27
27
|
const now = context.now();
|
|
28
|
-
context.db.
|
|
29
|
-
|
|
28
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
29
|
+
try {
|
|
30
|
+
const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
|
|
31
|
+
if (paused.changes !== 1)
|
|
32
|
+
throw new AppError("invalid_work_state", "Work changed before pause could be accepted", 1, { work_id: work.work_id });
|
|
33
|
+
pauseFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, pauseId, questionPath });
|
|
34
|
+
context.db.exec("COMMIT");
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
context.db.exec("ROLLBACK");
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
30
40
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
31
41
|
const resume = stageResumeCommand(context, { runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot });
|
|
32
42
|
const resumeTemplate = `${resume} <<'USER_ANSWER'\n<paste the complete user answer exactly>\nUSER_ANSWER`;
|
|
@@ -61,26 +71,31 @@ export function resumeStageAfterUser(context, input) {
|
|
|
61
71
|
errorCode: "trusted_stage_resume_required",
|
|
62
72
|
operation: "stage resume"
|
|
63
73
|
}).eventKey;
|
|
64
|
-
const identity = claimStageResumeHookEvent(context, {
|
|
65
|
-
projectId: project.id,
|
|
66
|
-
runId: run.id,
|
|
67
|
-
stage: stage.stage,
|
|
68
|
-
workId: work.work_id,
|
|
69
|
-
projectRoot,
|
|
70
|
-
eventKey: hookEventId
|
|
71
|
-
});
|
|
72
74
|
const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
|
|
73
75
|
fs.writeFileSync(answerPath, input.answer);
|
|
74
76
|
const now = context.now();
|
|
75
77
|
const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
|
|
76
78
|
if (!workSession)
|
|
77
79
|
throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
context.db.exec("BEGIN IMMEDIATE");
|
|
81
|
+
let identity;
|
|
82
|
+
try {
|
|
83
|
+
identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
|
|
84
|
+
if (workSession.session_id !== identity.sessionId) {
|
|
85
|
+
bindSessionForResume(context, work.work_id, identity, now);
|
|
86
|
+
context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
|
|
87
|
+
context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [`WSES-${crypto.randomUUID()}`, work.work_id, identity.sessionId, identity.hookEventId, workSession.prompt_path, workSession.result_path, now, now]);
|
|
88
|
+
}
|
|
89
|
+
const resumed = context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
|
|
90
|
+
if (resumed.changes !== 1)
|
|
91
|
+
throw new AppError("invalid_work_state", "Work changed before resume could be accepted", 1, { work_id: work.work_id });
|
|
92
|
+
resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
|
|
93
|
+
context.db.exec("COMMIT");
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
context.db.exec("ROLLBACK");
|
|
97
|
+
throw error;
|
|
81
98
|
}
|
|
82
|
-
context.db.run("UPDATE works SET status = 'running', updated_at = ? WHERE work_id = ? AND status = 'paused'", [now, work.work_id]);
|
|
83
|
-
resumeFlowRunStage(context, { projectRoot, runId: run.id, stage: stage.stage, workId: work.work_id, answerPath });
|
|
84
99
|
refreshRunWorkProjection(context, project.id, run.id);
|
|
85
100
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
|
|
86
101
|
const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
|
|
@@ -152,7 +167,7 @@ export function flowCommand(context) {
|
|
|
152
167
|
return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
|
|
153
168
|
}
|
|
154
169
|
function requireRun(context, projectId, id) {
|
|
155
|
-
const rows = context.db.all("SELECT id, short_id, project_id, project_root,
|
|
170
|
+
const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
|
|
156
171
|
if (rows.length !== 1)
|
|
157
172
|
throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
|
|
158
173
|
return rows[0];
|
|
@@ -171,9 +186,9 @@ function requireStage(run, stage, status) {
|
|
|
171
186
|
return found;
|
|
172
187
|
}
|
|
173
188
|
function requireRunHome(run) {
|
|
174
|
-
if (!run.
|
|
175
|
-
throw new AppError("runtime_missing", "RUN has no artifact
|
|
176
|
-
return run.
|
|
189
|
+
if (!run.run_root)
|
|
190
|
+
throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
|
|
191
|
+
return run.run_root;
|
|
177
192
|
}
|
|
178
193
|
function nextPauseId(runHome) {
|
|
179
194
|
const root = path.join(runHome, "intake", "hitl");
|