@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.10
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 +70 -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 +25 -22
- 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
package/dist/services/hooks.js
CHANGED
|
@@ -7,6 +7,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
7
7
|
import { parseJsonObject } from "../shared/json.js";
|
|
8
8
|
import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
|
|
9
9
|
import { appendAudit } from "./audit.js";
|
|
10
|
+
import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
|
|
10
11
|
import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
|
|
11
12
|
import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
12
13
|
import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
|
|
@@ -278,7 +279,7 @@ export function handleCodexHook(context, input) {
|
|
|
278
279
|
return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
|
|
279
280
|
// Resume legitimately receives an answer through stdin. It is matched by
|
|
280
281
|
// immutable lifecycle arguments below; never reject or rewrite that pipe.
|
|
281
|
-
if (lifecycle.analysis.kind === "compound"
|
|
282
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
282
283
|
return {
|
|
283
284
|
ok: false,
|
|
284
285
|
observed: false,
|
|
@@ -303,10 +304,11 @@ export function handleCodexHook(context, input) {
|
|
|
303
304
|
if (!project)
|
|
304
305
|
return { ok: true, observed: false, reason: "unrelated_cwd" };
|
|
305
306
|
const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
|
|
307
|
+
const storageId = sessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", sessionId)) : null;
|
|
306
308
|
const observedSession = flowPayload
|
|
307
|
-
? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.
|
|
309
|
+
? bindObservedFlowSession(context, project, { ...flowPayload, harness: "codex-desktop", provider_session_id: sessionId ?? flowPayload.provider_session_id ?? null }, storageId ?? undefined)
|
|
308
310
|
: undefined;
|
|
309
|
-
const effectiveSessionId = observedSession?.session_id ??
|
|
311
|
+
const effectiveSessionId = observedSession?.session_id ?? storageId;
|
|
310
312
|
const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
|
|
311
313
|
const eventKey = hookEventKey(payload, eventName, toolName, command);
|
|
312
314
|
const inserted = recordHookEvent(context, {
|
|
@@ -343,7 +345,7 @@ export function handleCodexHook(context, input) {
|
|
|
343
345
|
observed: inserted,
|
|
344
346
|
duplicate: !inserted,
|
|
345
347
|
event_key: eventKey,
|
|
346
|
-
|
|
348
|
+
session: sessionId ? nativeSessionIdentity("codex-desktop", sessionId) : null,
|
|
347
349
|
protocol_id: protocolId,
|
|
348
350
|
...(sessionId && (flowPayload || stageStart || stageResume || workStart)
|
|
349
351
|
? {
|
|
@@ -377,7 +379,7 @@ export function handleZcodeEvent(context, input) {
|
|
|
377
379
|
const lifecycle = lifecycleFacts(command);
|
|
378
380
|
if (!lifecycle)
|
|
379
381
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
380
|
-
if (lifecycle.analysis.kind === "compound"
|
|
382
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
381
383
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
|
|
382
384
|
standalone_command: lifecycle.invocation.command
|
|
383
385
|
});
|
|
@@ -403,8 +405,8 @@ export function handleZcodeEvent(context, input) {
|
|
|
403
405
|
throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
|
|
404
406
|
const childProviderSessionId = stringValue(runtime.childSessionId);
|
|
405
407
|
const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
|
|
406
|
-
const sessionId =
|
|
407
|
-
const parentSessionId = childProviderSessionId ?
|
|
408
|
+
const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
|
|
409
|
+
const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
|
|
408
410
|
const agentId = stringValue(runtime.agentId);
|
|
409
411
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
410
412
|
...flowPayload,
|
|
@@ -463,9 +465,8 @@ export function handleZcodeEvent(context, input) {
|
|
|
463
465
|
duplicate: !inserted,
|
|
464
466
|
event_key: eventKey,
|
|
465
467
|
harness: "zcode-acp",
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
parent_session_id: parentSessionId,
|
|
468
|
+
session: nativeSessionIdentity("zcode-acp", providerSessionId),
|
|
469
|
+
...(childProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", rootProviderSessionId) } : {}),
|
|
469
470
|
daemon_id: daemonId ?? null
|
|
470
471
|
};
|
|
471
472
|
}
|
|
@@ -485,7 +486,7 @@ export function handleGrokEvent(context, input) {
|
|
|
485
486
|
const lifecycle = lifecycleFacts(command);
|
|
486
487
|
if (!lifecycle)
|
|
487
488
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
488
|
-
if (lifecycle.analysis.kind === "compound"
|
|
489
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
489
490
|
throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
|
|
490
491
|
standalone_command: lifecycle.invocation.command
|
|
491
492
|
});
|
|
@@ -509,8 +510,8 @@ export function handleGrokEvent(context, input) {
|
|
|
509
510
|
if (!rootProviderSessionId || !providerSessionId)
|
|
510
511
|
throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
|
|
511
512
|
const isChild = providerSessionId !== rootProviderSessionId;
|
|
512
|
-
const sessionId =
|
|
513
|
-
const parentSessionId = isChild ?
|
|
513
|
+
const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
|
|
514
|
+
const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
|
|
514
515
|
const daemonId = stringValue(ddGrok.daemonId);
|
|
515
516
|
const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
|
|
516
517
|
const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
|
|
@@ -532,8 +533,8 @@ export function handleGrokEvent(context, input) {
|
|
|
532
533
|
projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
|
|
533
534
|
cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
|
|
534
535
|
});
|
|
535
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey,
|
|
536
|
-
|
|
536
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
|
|
537
|
+
...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
|
|
537
538
|
return (flowPayload || stageStart || stageResume || workStart)
|
|
538
539
|
? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
|
|
539
540
|
: result;
|
|
@@ -565,8 +566,8 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
565
566
|
if (resolveProjectRoot(directory) !== expectedRoot) {
|
|
566
567
|
throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
|
|
567
568
|
}
|
|
568
|
-
const sessionId =
|
|
569
|
-
const parentSessionId = nativeParentId ?
|
|
569
|
+
const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
|
|
570
|
+
const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
|
|
570
571
|
const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
|
|
571
572
|
const baseKey = `${eventId}:${phase}`;
|
|
572
573
|
if (phase === "after") {
|
|
@@ -577,7 +578,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
577
578
|
eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
|
|
578
579
|
eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
|
|
579
580
|
});
|
|
580
|
-
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
581
|
+
return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
|
|
581
582
|
}
|
|
582
583
|
if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
|
|
583
584
|
return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
|
|
@@ -585,7 +586,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
585
586
|
const lifecycle = lifecycleFacts(command);
|
|
586
587
|
if (!lifecycle)
|
|
587
588
|
return { ok: true, observed: false, reason: "event_not_participating" };
|
|
588
|
-
if (lifecycle.analysis.kind === "compound"
|
|
589
|
+
if (lifecycle.analysis.kind === "compound") {
|
|
589
590
|
throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
|
|
590
591
|
}
|
|
591
592
|
const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
|
|
@@ -614,7 +615,7 @@ export function handleOpenCodeEvent(context, input) {
|
|
|
614
615
|
status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
|
|
615
616
|
});
|
|
616
617
|
recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
|
|
617
|
-
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness,
|
|
618
|
+
const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
|
|
618
619
|
return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
|
|
619
620
|
}
|
|
620
621
|
/** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
|
|
@@ -736,7 +737,7 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
|
|
|
736
737
|
/** Reads immutable identity facts already captured by PreToolUse. */
|
|
737
738
|
export function hookSessionIdentity(context, projectId, eventKey) {
|
|
738
739
|
const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
|
|
739
|
-
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
|
|
740
|
+
FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = COALESCE(he.provider_session_id, he.session_id)
|
|
740
741
|
WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
|
|
741
742
|
if (!event?.session_id)
|
|
742
743
|
throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
|
|
@@ -747,7 +748,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
|
|
|
747
748
|
parentSessionId: event.parent_session_id,
|
|
748
749
|
daemonId: event.daemon_id,
|
|
749
750
|
agentId: event.agent_id,
|
|
750
|
-
|
|
751
|
+
// agent_id is an auxiliary provider fact, never a replacement Session ID.
|
|
752
|
+
sessionId: event.session_id,
|
|
753
|
+
nativeSessionId: event.provider_session_id ?? event.session_id,
|
|
751
754
|
turnId: event.turn_id,
|
|
752
755
|
transcriptPath: event.transcript_path,
|
|
753
756
|
provider: event.provider,
|
package/dist/services/lanes.js
CHANGED
|
@@ -288,6 +288,7 @@ export async function waitAcquireLaneLock(context, input) {
|
|
|
288
288
|
timeoutSeconds
|
|
289
289
|
});
|
|
290
290
|
}
|
|
291
|
+
input.progress?.(`lane ${lane} is waiting at queue position ${String(result.payload.position ?? "unknown")}; next update in ${pollIntervalSeconds} seconds`);
|
|
291
292
|
await delay(pollIntervalSeconds * 1000);
|
|
292
293
|
}
|
|
293
294
|
}
|
|
@@ -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)); }
|
|
@@ -5,6 +5,7 @@ import { AppError } from "../shared/errors.js";
|
|
|
5
5
|
import { flowCommand } from "./stage-pause.js";
|
|
6
6
|
import { validateSchema } from "./schema-validation.js";
|
|
7
7
|
import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
8
|
+
import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
|
|
8
9
|
/** A deterministic dispatcher; only the launched harness Session performs agent work. */
|
|
9
10
|
export async function serveMergeRequests(context, input) {
|
|
10
11
|
const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
|
|
@@ -63,6 +64,8 @@ async function dispatch(context, input) {
|
|
|
63
64
|
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
65
|
if (claimed.changes !== 1)
|
|
65
66
|
return;
|
|
67
|
+
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);
|
|
68
|
+
leaseHeartbeat.unref();
|
|
66
69
|
const stateDir = path.join(input.root, input.request.merge_request_id);
|
|
67
70
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
68
71
|
const promptFile = path.join(stateDir, "launch.md");
|
|
@@ -71,8 +74,8 @@ async function dispatch(context, input) {
|
|
|
71
74
|
fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
|
|
72
75
|
event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
|
|
73
76
|
input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
|
|
74
|
-
const executable = context.
|
|
75
|
-
const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, "--json"];
|
|
77
|
+
const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
|
|
78
|
+
const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
|
|
76
79
|
try {
|
|
77
80
|
const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
|
|
78
81
|
await adapterCall(["daemon", "start", ...common]);
|
|
@@ -95,6 +98,9 @@ async function dispatch(context, input) {
|
|
|
95
98
|
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
99
|
throw error;
|
|
97
100
|
}
|
|
101
|
+
finally {
|
|
102
|
+
clearInterval(leaseHeartbeat);
|
|
103
|
+
}
|
|
98
104
|
}
|
|
99
105
|
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
106
|
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
|
+
}
|
package/dist/services/prompts.js
CHANGED
|
@@ -136,7 +136,9 @@ export function renderWorkerPrompt(context, input) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
function runHomePath(run) {
|
|
139
|
-
|
|
139
|
+
if (!run.run_root)
|
|
140
|
+
throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
|
|
141
|
+
return run.run_root;
|
|
140
142
|
}
|
|
141
143
|
function readWorkerTask(taskFile, runHome) {
|
|
142
144
|
const resolved = path.resolve(taskFile);
|
|
@@ -204,7 +206,7 @@ function protocolIdForRun(context, projectId, runId) {
|
|
|
204
206
|
return run.subject_id;
|
|
205
207
|
}
|
|
206
208
|
function requireRun(context, projectId, runId) {
|
|
207
|
-
const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root,
|
|
209
|
+
const row = context.db.get("SELECT id, project_id, subject_type, subject_id, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, runId, runId]);
|
|
208
210
|
if (!row)
|
|
209
211
|
throw new AppError("not_found", `Run is not found: ${runId}`, 1);
|
|
210
212
|
return row;
|
|
@@ -2,36 +2,18 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
4
|
export const runEngineBindingSchemaId = "dd-flow/run-engine-binding@1";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** Resolve a RUN from SQLite authority; never discover one by scanning storage paths. */
|
|
6
|
+
export function locateRunRoot(db, projectRoot, runIdOrAlias) {
|
|
7
|
+
const rows = db.all(`SELECT r.project_id, r.id AS run_id, r.run_root
|
|
8
|
+
FROM runs r JOIN projects p ON p.id = r.project_id
|
|
9
|
+
WHERE p.root = ? AND (r.id = ? OR r.short_id = ?)`, [realpathOrResolve(projectRoot), runIdOrAlias, runIdOrAlias]).filter((row) => Boolean(row.run_root));
|
|
10
|
+
if (rows.length > 1)
|
|
11
|
+
throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, { project_root: realpathOrResolve(projectRoot), candidates: rows.map((row) => row.run_id) });
|
|
12
|
+
const row = rows[0];
|
|
13
|
+
if (!row?.run_root)
|
|
8
14
|
return null;
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
for (const projectId of fs.readdirSync(projectsRoot)) {
|
|
12
|
-
const runsRoot = path.join(projectsRoot, projectId, "runs");
|
|
13
|
-
if (!isDirectory(runsRoot))
|
|
14
|
-
continue;
|
|
15
|
-
for (const runId of fs.readdirSync(runsRoot)) {
|
|
16
|
-
if (!runIdMatches(runId, runIdOrAlias))
|
|
17
|
-
continue;
|
|
18
|
-
const runHome = path.join(runsRoot, runId);
|
|
19
|
-
const authorityPath = path.join(runHome, "run.json");
|
|
20
|
-
const bindingPath = path.join(runHome, "engine-binding.json");
|
|
21
|
-
const binding = readRunEngineBinding(bindingPath, { allowMissing: true });
|
|
22
|
-
const authorityRoot = binding?.project_root ?? projectRootFromAuthority(authorityPath);
|
|
23
|
-
if (!authorityRoot || realpathOrResolve(authorityRoot) !== expectedRoot)
|
|
24
|
-
continue;
|
|
25
|
-
matches.push({ project_id: projectId, run_id: runId, run_home: runHome, authority_path: authorityPath, binding_path: bindingPath });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
if (matches.length > 1) {
|
|
29
|
-
throw new AppError("ambiguous_id", `RUN id is ambiguous for project: ${runIdOrAlias}`, 2, {
|
|
30
|
-
project_root: expectedRoot,
|
|
31
|
-
candidates: matches.map((match) => match.run_id)
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
return matches[0] ?? null;
|
|
15
|
+
const runRoot = path.resolve(row.run_root);
|
|
16
|
+
return { project_id: row.project_id, run_id: row.run_id, run_root: runRoot, authority_path: path.join(runRoot, "run.json"), binding_path: path.join(runRoot, "engine-binding.json") };
|
|
35
17
|
}
|
|
36
18
|
export function readRunEngineBinding(file, options = {}) {
|
|
37
19
|
if (!fs.existsSync(file)) {
|
|
@@ -86,6 +68,14 @@ export function allRunEngineBindings(ddFlowHome) {
|
|
|
86
68
|
}
|
|
87
69
|
return bindings;
|
|
88
70
|
}
|
|
71
|
+
function isDirectory(value) {
|
|
72
|
+
try {
|
|
73
|
+
return fs.statSync(value).isDirectory();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
89
79
|
function isRunEngineBinding(value) {
|
|
90
80
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
91
81
|
return false;
|
|
@@ -115,32 +105,6 @@ function sameEngine(left, right) {
|
|
|
115
105
|
&& left.integrity_checksum === right.integrity_checksum
|
|
116
106
|
&& left.snapshot_root === right.snapshot_root;
|
|
117
107
|
}
|
|
118
|
-
function projectRootFromAuthority(file) {
|
|
119
|
-
try {
|
|
120
|
-
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
121
|
-
const execution = recordValue(value.execution);
|
|
122
|
-
const workspace = recordValue(value.workspace);
|
|
123
|
-
const project = recordValue(value.project);
|
|
124
|
-
return stringValue(execution?.project_root)
|
|
125
|
-
?? stringValue(workspace?.project_root)
|
|
126
|
-
?? stringValue(project?.root)
|
|
127
|
-
?? stringValue(value.project_root);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
function runIdMatches(fullId, idOrAlias) {
|
|
134
|
-
return fullId === idOrAlias || /^RUN-[0-9]+$/.test(idOrAlias) && fullId.startsWith(`${idOrAlias}-`);
|
|
135
|
-
}
|
|
136
|
-
function isDirectory(value) {
|
|
137
|
-
try {
|
|
138
|
-
return fs.statSync(value).isDirectory();
|
|
139
|
-
}
|
|
140
|
-
catch {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
108
|
function realpathOrResolve(value) {
|
|
145
109
|
try {
|
|
146
110
|
return fs.realpathSync(value);
|
|
@@ -149,9 +113,3 @@ function realpathOrResolve(value) {
|
|
|
149
113
|
return path.resolve(value);
|
|
150
114
|
}
|
|
151
115
|
}
|
|
152
|
-
function recordValue(value) {
|
|
153
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
154
|
-
}
|
|
155
|
-
function stringValue(value) {
|
|
156
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
157
|
-
}
|
|
@@ -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,
|