@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.7
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 +49 -0
- package/dist/build-info.json +10 -10
- package/dist/cli/help.js +2 -2
- package/dist/cli/run-cli.js +105 -7
- package/dist/schemas/code-work-batch.schema.json +3 -3
- 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/eval-snapshots.js +5 -2
- package/dist/services/hooks.js +25 -22
- package/dist/services/lanes.js +1 -0
- package/dist/services/managed-processes.js +169 -0
- package/dist/services/merge-server.js +5 -0
- package/dist/services/portable-refs.js +57 -0
- package/dist/services/run-projection.js +10 -8
- package/dist/services/runs.js +50 -6
- package/dist/services/session-identity.js +19 -0
- package/dist/services/sessions.js +26 -11
- package/dist/services/stage-pause.js +31 -16
- package/dist/services/usage.js +74 -42
- package/dist/services/vnext-code-review.js +46 -12
- package/dist/services/vnext-code.js +59 -22
- package/dist/services/vnext-merge.js +102 -42
- package/dist/services/vnext-plan-review.js +31 -13
- package/dist/services/vnext-plan.js +57 -9
- package/dist/services/work-registry.js +130 -27
- package/dist/storage/database.js +125 -2
- package/package.json +12 -12
- package/tools/audit-runtime-fix-boundaries.mjs +96 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
5
|
+
const defaultLeaseMs = 15 * 60_000;
|
|
6
|
+
export function resourceHome(context) {
|
|
7
|
+
return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
|
|
8
|
+
}
|
|
9
|
+
export function registerManagedProcess(context, input) {
|
|
10
|
+
const db = registry(context);
|
|
11
|
+
const now = context.now();
|
|
12
|
+
const id = input.id ?? `PROC-${crypto.randomUUID()}`;
|
|
13
|
+
const token = crypto.randomUUID();
|
|
14
|
+
db.run(`INSERT INTO managed_processes
|
|
15
|
+
(id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
|
|
16
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata ?? {})]);
|
|
17
|
+
return requireProcess(db, id);
|
|
18
|
+
}
|
|
19
|
+
export function confirmManagedProcess(context, input) {
|
|
20
|
+
const db = registry(context);
|
|
21
|
+
const now = context.now();
|
|
22
|
+
const current = requireProcess(db, input.id);
|
|
23
|
+
const metadata = parseMetadata(current.metadata_json);
|
|
24
|
+
if (input.processGroupId)
|
|
25
|
+
metadata.process_group_id = input.processGroupId;
|
|
26
|
+
const result = db.run(`UPDATE managed_processes
|
|
27
|
+
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
28
|
+
WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
|
|
29
|
+
if (result.changes !== 1)
|
|
30
|
+
throw new Error(`Managed process cannot be confirmed: ${input.id}`);
|
|
31
|
+
return requireProcess(db, input.id);
|
|
32
|
+
}
|
|
33
|
+
export function heartbeatManagedProcess(context, input) {
|
|
34
|
+
const db = registry(context);
|
|
35
|
+
return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
|
|
36
|
+
}
|
|
37
|
+
export function finishManagedProcess(context, input) {
|
|
38
|
+
const db = registry(context);
|
|
39
|
+
const now = context.now();
|
|
40
|
+
const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
|
|
41
|
+
if (updated)
|
|
42
|
+
db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
|
|
43
|
+
return updated;
|
|
44
|
+
}
|
|
45
|
+
export function processIsAlive(record) {
|
|
46
|
+
if (!record.pid)
|
|
47
|
+
return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(record.pid, 0);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
|
|
55
|
+
}
|
|
56
|
+
function processTreeIsAlive(record) {
|
|
57
|
+
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
58
|
+
if (process.platform !== "win32" && typeof group === "number") {
|
|
59
|
+
try {
|
|
60
|
+
process.kill(-group, 0);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return processIsAlive(record);
|
|
68
|
+
}
|
|
69
|
+
/** Claims only expired records. Callers must still verify `processIsAlive` before stopping a PID. */
|
|
70
|
+
export function claimExpiredManagedProcesses(context, ownerId) {
|
|
71
|
+
const db = registry(context);
|
|
72
|
+
const now = context.now();
|
|
73
|
+
db.exec("BEGIN IMMEDIATE");
|
|
74
|
+
try {
|
|
75
|
+
const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
|
|
76
|
+
const claimed = [];
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
const token = crypto.randomUUID();
|
|
79
|
+
const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
|
|
80
|
+
if (result.changes === 1)
|
|
81
|
+
claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
|
|
82
|
+
}
|
|
83
|
+
db.exec("COMMIT");
|
|
84
|
+
return claimed;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
db.exec("ROLLBACK");
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function managedProcessStatus(context) {
|
|
92
|
+
return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
|
|
93
|
+
}
|
|
94
|
+
/** Reconcile only records the system owns and has atomically claimed. */
|
|
95
|
+
export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
|
|
96
|
+
const claimed = claimExpiredManagedProcesses(context, ownerId);
|
|
97
|
+
const outcomes = [];
|
|
98
|
+
for (const processRecord of claimed) {
|
|
99
|
+
if (!processTreeIsAlive(processRecord)) {
|
|
100
|
+
finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_not_alive" });
|
|
101
|
+
outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
terminateOwnedProcess(processRecord, "SIGTERM");
|
|
105
|
+
await delay(graceMs);
|
|
106
|
+
if (processTreeIsAlive(processRecord)) {
|
|
107
|
+
terminateOwnedProcess(processRecord, "SIGKILL");
|
|
108
|
+
await delay(Math.min(graceMs, 250));
|
|
109
|
+
}
|
|
110
|
+
if (processTreeIsAlive(processRecord)) {
|
|
111
|
+
outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
|
|
115
|
+
outcomes.push({ id: processRecord.id, outcome: "stopped" });
|
|
116
|
+
}
|
|
117
|
+
return outcomes;
|
|
118
|
+
}
|
|
119
|
+
export async function reservePorts(context, input) {
|
|
120
|
+
const db = registry(context);
|
|
121
|
+
const claims = [];
|
|
122
|
+
const ports = {};
|
|
123
|
+
try {
|
|
124
|
+
for (const name of input.names) {
|
|
125
|
+
for (;;) {
|
|
126
|
+
const port = await availablePort();
|
|
127
|
+
const key = String(port);
|
|
128
|
+
const token = crypto.randomUUID();
|
|
129
|
+
const now = context.now();
|
|
130
|
+
const result = db.run("INSERT OR IGNORE INTO managed_resources (resource_kind, resource_key, owner_id, lease_token, lease_expires_at, process_id, metadata_json, created_at, updated_at) VALUES ('port', ?, ?, ?, ?, ?, ?, ?, ?)", [key, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.processId ?? null, JSON.stringify({ name }), now, now]);
|
|
131
|
+
if (result.changes === 1) {
|
|
132
|
+
claims.push({ key, token });
|
|
133
|
+
ports[name] = port;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
releasePortClaims(db, claims);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return { ports, release: () => releasePortClaims(db, claims) };
|
|
144
|
+
}
|
|
145
|
+
function registry(context) { return getResourceDatabase(resourceHome(context)); }
|
|
146
|
+
function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
|
|
147
|
+
throw new Error(`Managed process is missing: ${id}`); return row; }
|
|
148
|
+
function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
|
|
149
|
+
function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
150
|
+
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
151
|
+
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
152
|
+
function terminateOwnedProcess(record, signal) {
|
|
153
|
+
if (!record.pid || !record.pid_started_at || !processTreeIsAlive(record))
|
|
154
|
+
return;
|
|
155
|
+
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
156
|
+
try {
|
|
157
|
+
process.kill(process.platform === "win32" || typeof group !== "number" ? record.pid : -group, signal);
|
|
158
|
+
}
|
|
159
|
+
catch { /* inspect again below */ }
|
|
160
|
+
}
|
|
161
|
+
function parseMetadata(value) { try {
|
|
162
|
+
const parsed = JSON.parse(value);
|
|
163
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return {};
|
|
167
|
+
} }
|
|
168
|
+
function availablePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : undefined; server.close((error) => error ? reject(error) : port ? resolve(port) : reject(new Error("Could not allocate local port"))); }); }); }
|
|
169
|
+
function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }
|
|
@@ -63,6 +63,8 @@ async function dispatch(context, input) {
|
|
|
63
63
|
const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
|
|
64
64
|
if (claimed.changes !== 1)
|
|
65
65
|
return;
|
|
66
|
+
const leaseHeartbeat = setInterval(() => { const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
|
|
67
|
+
leaseHeartbeat.unref();
|
|
66
68
|
const stateDir = path.join(input.root, input.request.merge_request_id);
|
|
67
69
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
68
70
|
const promptFile = path.join(stateDir, "launch.md");
|
|
@@ -95,6 +97,9 @@ async function dispatch(context, input) {
|
|
|
95
97
|
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
|
|
96
98
|
throw error;
|
|
97
99
|
}
|
|
100
|
+
finally {
|
|
101
|
+
clearInterval(leaseHeartbeat);
|
|
102
|
+
}
|
|
98
103
|
}
|
|
99
104
|
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
100
105
|
return false; selected.add(request.project_id); return true; }).slice(0, limit); }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppError } from "../shared/errors.js";
|
|
4
|
+
export function assertPortableArtifactRef(value, input) {
|
|
5
|
+
const parsed = parseArtifactRef(value);
|
|
6
|
+
let file;
|
|
7
|
+
let root;
|
|
8
|
+
if (parsed.path.startsWith("run://")) {
|
|
9
|
+
const prefix = `run://${input.runId}/`;
|
|
10
|
+
if (!parsed.path.startsWith(prefix) || parsed.path.slice(prefix.length).split("/").includes(".."))
|
|
11
|
+
throw new AppError("invalid_evidence_ref", "Artifact RUN reference must stay in the current RUN", 2, { ref: value });
|
|
12
|
+
root = input.runHome;
|
|
13
|
+
file = path.join(root, parsed.path.slice(prefix.length));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
root = input.workspaceRoot;
|
|
17
|
+
if (!parsed.path)
|
|
18
|
+
throw new AppError("invalid_evidence_ref", "Artifact reference must name a file", 2, { ref: value });
|
|
19
|
+
file = path.isAbsolute(parsed.path) ? parsed.path : path.join(root, parsed.path);
|
|
20
|
+
}
|
|
21
|
+
if (!fs.existsSync(file))
|
|
22
|
+
throw new AppError("evidence_ref_missing", "Artifact reference does not exist", 2, { ref: value, resolved_path: file });
|
|
23
|
+
const realRoot = fs.realpathSync(root);
|
|
24
|
+
const realFile = fs.realpathSync(file);
|
|
25
|
+
if (!contained(realRoot, realFile))
|
|
26
|
+
throw new AppError("invalid_evidence_ref", "Artifact reference must stay inside its declared workspace", 2, { ref: value, resolved_path: realFile, root: realRoot });
|
|
27
|
+
if (parsed.lines)
|
|
28
|
+
validateLineRanges(realFile, parsed.lines, value);
|
|
29
|
+
return realFile;
|
|
30
|
+
}
|
|
31
|
+
function parseArtifactRef(value) {
|
|
32
|
+
if (typeof value !== "string" || !value.trim())
|
|
33
|
+
throw new AppError("invalid_evidence_ref", "Artifact reference must be a non-empty string", 2, { ref: value });
|
|
34
|
+
const [rawPath, ...fragments] = value.split("#");
|
|
35
|
+
if (fragments.length > 1 || (fragments.length === 1 && !fragments[0]))
|
|
36
|
+
throw new AppError("invalid_evidence_ref", "Artifact reference has an invalid fragment", 2, { ref: value });
|
|
37
|
+
const match = rawPath.match(/^(.*):(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)$/);
|
|
38
|
+
if (!match)
|
|
39
|
+
return { path: rawPath, fragment: fragments[0] ?? null, lines: null };
|
|
40
|
+
const lines = match[2].split(",").map((item) => {
|
|
41
|
+
const [startText, endText] = item.split("-");
|
|
42
|
+
const start = Number(startText);
|
|
43
|
+
const end = Number(endText ?? startText);
|
|
44
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start)
|
|
45
|
+
throw new AppError("invalid_evidence_ref", "Artifact line ranges must be positive and ordered", 2, { ref: value, range: item });
|
|
46
|
+
return { start, end };
|
|
47
|
+
});
|
|
48
|
+
return { path: match[1], fragment: fragments[0] ?? null, lines };
|
|
49
|
+
}
|
|
50
|
+
function contained(root, file) { const relative = path.relative(root, file); return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); }
|
|
51
|
+
function validateLineRanges(file, ranges, ref) {
|
|
52
|
+
const source = fs.readFileSync(file, "utf8");
|
|
53
|
+
const lines = source === "" ? 0 : source.replace(/\r?\n$/, "").split(/\r?\n/).length;
|
|
54
|
+
const invalid = ranges.find((range) => range.end > lines);
|
|
55
|
+
if (invalid)
|
|
56
|
+
throw new AppError("evidence_line_out_of_range", "Artifact line range exceeds the referenced file", 2, { ref, file, line_count: lines, invalid_range: invalid });
|
|
57
|
+
}
|
|
@@ -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";
|
|
@@ -450,6 +452,7 @@ export function attachFlowRunStage(context, input) {
|
|
|
450
452
|
const dir = requiredStageDir(input.dir);
|
|
451
453
|
const status = parseStageStatus(input.status);
|
|
452
454
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
455
|
+
assertVnextStageStart(run, index, stage, status, existing);
|
|
453
456
|
if (status === "running" && existing) {
|
|
454
457
|
archiveExistingStageAttempt(runArtifactRoot(run), dir);
|
|
455
458
|
}
|
|
@@ -674,8 +677,13 @@ export function completeFlowRun(context, input) {
|
|
|
674
677
|
const index = authoritativeIndex(run);
|
|
675
678
|
const now = context.now();
|
|
676
679
|
const status = parseRunStatus(input.status);
|
|
677
|
-
if (!['done', '
|
|
678
|
-
throw new AppError('validation', 'completeFlowRun accepts
|
|
680
|
+
if (!['done', 'cancelled', 'failed'].includes(status)) {
|
|
681
|
+
throw new AppError('validation', 'completeFlowRun accepts only done, cancelled, or failed; use stage block/unblock for a recoverable blocker', 2, { status });
|
|
682
|
+
}
|
|
683
|
+
if (!input.manualOverrideReason) {
|
|
684
|
+
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]);
|
|
685
|
+
if (activeWork)
|
|
686
|
+
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
687
|
}
|
|
680
688
|
if (input.manualOverrideReason && (status === "cancelled" || status === "failed")) {
|
|
681
689
|
closeOpenStagesForOverride(index, status, now);
|
|
@@ -685,7 +693,7 @@ export function completeFlowRun(context, input) {
|
|
|
685
693
|
index.verdict = input.verdict ?? (status === "done" ? "accepted" : status);
|
|
686
694
|
index.next_action = input.nextAction ?? null;
|
|
687
695
|
index.updated_at = now;
|
|
688
|
-
if (["done", "
|
|
696
|
+
if (["done", "cancelled", "failed"].includes(status)) {
|
|
689
697
|
index.completed_at = now;
|
|
690
698
|
index.finished_at = now;
|
|
691
699
|
index.duration_ms = durationMs(index.started_at ?? run.created_at, now);
|
|
@@ -733,7 +741,7 @@ export function advanceFlowRun(context, input) {
|
|
|
733
741
|
function closeOpenStagesForOverride(index, status, now) {
|
|
734
742
|
const stageStatus = status === "cancelled" ? "skipped" : "failed";
|
|
735
743
|
for (const stage of index.stage_runs) {
|
|
736
|
-
if (stage.status !== "running" && stage.status !== "pending")
|
|
744
|
+
if (stage.status !== "running" && stage.status !== "pending" && stage.status !== "paused" && stage.status !== "blocked")
|
|
737
745
|
continue;
|
|
738
746
|
stage.status = stageStatus;
|
|
739
747
|
stage.updated_at = now;
|
|
@@ -755,7 +763,7 @@ function closeOpenWorksForOverride(context, projectId, runId, status, now) {
|
|
|
755
763
|
context.db.run(`UPDATE works
|
|
756
764
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
757
765
|
WHERE project_id = ? AND run_id = ?
|
|
758
|
-
AND status IN ('created', 'running')`, [workStatus, now, now, projectId, runId]);
|
|
766
|
+
AND status IN ('created', 'running', 'paused')`, [workStatus, now, now, projectId, runId]);
|
|
759
767
|
context.db.run(`UPDATE work_sessions
|
|
760
768
|
SET status = ?, updated_at = ?, completed_at = ?
|
|
761
769
|
WHERE work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)
|
|
@@ -881,7 +889,21 @@ export function getFlowRunSessions(context, input) {
|
|
|
881
889
|
s.agent_id, s.worker_id, s.provider, s.model, s.reasoning, s.mode,
|
|
882
890
|
s.agent_type, s.transcript_path
|
|
883
891
|
ORDER BY s.created_at, s.session_id`, [project.id, run.id]);
|
|
884
|
-
|
|
892
|
+
const identities = new Map(sessions.map((row) => [String(row.session_id), publicSessionIdentity({
|
|
893
|
+
harness: String(row.harness),
|
|
894
|
+
provider_session_id: typeof row.provider_session_id === "string" ? row.provider_session_id : null,
|
|
895
|
+
session_id: String(row.session_id)
|
|
896
|
+
})]));
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
schema_id: "dd-flow/run-sessions@4",
|
|
900
|
+
run_id: run.id,
|
|
901
|
+
sessions: sessions.map(({ session_id, harness: _harness, provider_session_id: _providerSessionId, parent_session_id, ...row }) => ({
|
|
902
|
+
...row,
|
|
903
|
+
session: identities.get(String(session_id)) ?? null,
|
|
904
|
+
...(typeof parent_session_id === "string" ? { parent_session: identities.get(parent_session_id) ?? null } : {})
|
|
905
|
+
}))
|
|
906
|
+
};
|
|
885
907
|
}
|
|
886
908
|
export function appendFlowRunTimelineEvent(context, projectId, runId, event) {
|
|
887
909
|
const run = requireRunById(context, projectId, runId);
|
|
@@ -1133,6 +1155,28 @@ function nextAttempt(existing) {
|
|
|
1133
1155
|
const number = Number(existing.attempt.replace("try-", ""));
|
|
1134
1156
|
return `try-${String(Number.isFinite(number) ? number + 1 : 1).padStart(3, "0")}`;
|
|
1135
1157
|
}
|
|
1158
|
+
function assertVnextStageStart(run, index, stage, status, existing) {
|
|
1159
|
+
if (status !== "running" || (run.flow_kind !== "vnext_specify" && run.flow_kind !== "vnext_protocolize"))
|
|
1160
|
+
return;
|
|
1161
|
+
if (!vnextStages.some((candidate) => candidate.id === stage))
|
|
1162
|
+
return;
|
|
1163
|
+
if (existing) {
|
|
1164
|
+
if (existing.status === "running")
|
|
1165
|
+
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 });
|
|
1166
|
+
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 });
|
|
1167
|
+
}
|
|
1168
|
+
const completed = index.stage_runs.filter((candidate) => candidate.status === "done" || candidate.status === "skipped").sort((left, right) => right.order - left.order)[0];
|
|
1169
|
+
if (!completed) {
|
|
1170
|
+
if (stage === "specify")
|
|
1171
|
+
return;
|
|
1172
|
+
throw new AppError("illegal_stage_transition", "The vNext flow must start with SPECIFY", 2, { run_id: run.id, stage });
|
|
1173
|
+
}
|
|
1174
|
+
if (!isLegalVnextTransition(completed.stage, stage))
|
|
1175
|
+
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 });
|
|
1176
|
+
const unfinished = index.stage_runs.find((candidate) => !["done", "skipped"].includes(candidate.status));
|
|
1177
|
+
if (unfinished)
|
|
1178
|
+
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 });
|
|
1179
|
+
}
|
|
1136
1180
|
function upsertStage(index, stageRun) {
|
|
1137
1181
|
const position = index.stage_runs.findIndex((item) => item.stage === stageRun.stage);
|
|
1138
1182
|
if (position === -1) {
|
|
@@ -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,
|
|
@@ -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");
|