@deksden-com/dd-flow-cli 0.9.0-beta.120 → 0.9.0-beta.122
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 +9 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/hook-ingress.js +4 -3
- package/dist/cli/native-hook-ingress.js +34 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +6 -3
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +16 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +12 -2
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +60 -8
- package/dist/harness-runtime/lib/dd-zcode.mjs +28 -13
- package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
- package/dist/harness-runtime/lib/lifecycle-command.mjs +218 -0
- package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
- package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
- package/dist/harness-runtime/lib/native-hook-command.mjs +36 -5
- package/dist/runtime/context.js +1 -1
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/services/code-checks.js +40 -0
- package/dist/services/codex-hook-delivery.js +6 -2
- package/dist/services/hooks.js +51 -59
- package/dist/services/lifecycle-command.js +1 -383
- package/dist/services/lifecycle-contract.js +1 -30
- package/dist/services/lifecycle-invocations.js +34 -13
- package/dist/services/managed-processes.js +42 -6
- package/dist/services/run-control.js +7 -1
- package/dist/services/runs.js +60 -47
- package/dist/services/runtime-scope-stop.js +23 -2
- package/dist/services/stage-report-renderer.js +19 -0
- package/dist/services/verification-matrix.js +325 -0
- package/dist/services/vnext-code-review.js +25 -8
- package/dist/services/vnext-code.js +38 -11
- package/dist/services/vnext-merge.js +30 -7
- package/dist/services/vnext-plan-review.js +22 -6
- package/dist/services/vnext-plan.js +45 -17
- package/dist/services/work-registry.js +13 -1
- package/dist/storage/database.js +43 -9
- package/package.json +4 -1
|
@@ -777,7 +777,8 @@ export function issueLifecycleInvocation(context, command, scope) {
|
|
|
777
777
|
}
|
|
778
778
|
function commandWithInvocationId(command, id) {
|
|
779
779
|
const at = shellSuffixOffset(command);
|
|
780
|
-
|
|
780
|
+
const suffix = command.slice(at);
|
|
781
|
+
return `${command.slice(0, at)} --invocation-id ${id}${suffix ? /^\s/.test(suffix) ? suffix : ` ${suffix}` : ""}`;
|
|
781
782
|
}
|
|
782
783
|
/** The attempt owns authority; the caller owns presentation-only shell input.
|
|
783
784
|
* Preserve current stdin/heredoc/response-file text while validating that it
|
|
@@ -909,11 +910,7 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
909
910
|
storage(context);
|
|
910
911
|
const supplied = input.id ? context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]) : undefined;
|
|
911
912
|
const analysis = parseLifecycleCommand(input.command);
|
|
912
|
-
if (analysis.kind
|
|
913
|
-
if (analysis.kind !== "none" && ["grok-acp", "antigravity-cli"].includes(input.harness ?? "") && input.projectId
|
|
914
|
-
&& managedNativeOwner(context, input.identity, input.harness, input.projectId)) {
|
|
915
|
-
throw new AppError("invocation_command_invalid", "Managed native lifecycle call must be one direct standalone command", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
916
|
-
}
|
|
913
|
+
if (analysis.kind === "none" || analysis.supported === false) {
|
|
917
914
|
const eventKey = input.recordReceipt();
|
|
918
915
|
if (analysis.kind !== "none") {
|
|
919
916
|
const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE event_key = ?", [eventKey]);
|
|
@@ -928,22 +925,32 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
928
925
|
...outcome,
|
|
929
926
|
lifecycle_observation: {
|
|
930
927
|
code: "lifecycle_shell_syntax_invalid",
|
|
931
|
-
reason: analysis.kind === "compound" ? analysis.reason : "
|
|
928
|
+
reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command",
|
|
932
929
|
command_fingerprint: fingerprint(analysis.invocation),
|
|
933
930
|
effect: "no_effect",
|
|
934
931
|
recoverable: true
|
|
935
932
|
}
|
|
936
933
|
}), eventKey]);
|
|
937
934
|
}
|
|
935
|
+
if (analysis.kind !== "none")
|
|
936
|
+
throw new AppError("lifecycle_shell_syntax_invalid", "This shell form cannot bind exactly one lifecycle execution; use the exact issued lifecycle command without the unsupported shell context", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.kind === "compound" ? analysis.reason : "unsupported_lifecycle_command", event_key: eventKey });
|
|
938
937
|
return { eventKey, duplicate: false, invocationId: null };
|
|
939
938
|
}
|
|
940
|
-
|
|
939
|
+
// Admit the one parsed leaf, never execute or fingerprint the prefix/suffix.
|
|
940
|
+
const leafCommand = analysis.invocation.command;
|
|
941
|
+
if (input.executionCwd !== undefined && !path.isAbsolute(input.executionCwd))
|
|
942
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd must be absolute", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
943
|
+
const deliveredIdentity = JSON.stringify([input.identity.daemonId, input.identity.rootSessionId, input.identity.sessionId, input.identity.parentSessionId, input.identity.toolCallId,
|
|
944
|
+
...(input.executionCwd === undefined ? [] : [{ executionCwd: canonicalPath(input.executionCwd) }])]);
|
|
945
|
+
const candidates = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing','settled') ORDER BY rowid DESC")
|
|
941
946
|
.filter(row => {
|
|
947
|
+
if (["executing", "settled"].includes(row.status) && row.identity_json !== deliveredIdentity)
|
|
948
|
+
return false;
|
|
942
949
|
const scope = JSON.parse(row.scope_json);
|
|
943
950
|
if (scope.daemonId !== input.identity.daemonId || scope.rootSessionId !== input.identity.rootSessionId)
|
|
944
951
|
return false;
|
|
945
952
|
try {
|
|
946
|
-
return fingerprint(invocation(canonicalManagedCommand(context,
|
|
953
|
+
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
947
954
|
}
|
|
948
955
|
catch {
|
|
949
956
|
return false;
|
|
@@ -979,7 +986,7 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
979
986
|
const project = requireProjectByRoot(context, scope.projectRoot);
|
|
980
987
|
const identity = input.identity;
|
|
981
988
|
checkNativeIdentity(scope, identity, initial.id);
|
|
982
|
-
const identityJson =
|
|
989
|
+
const identityJson = deliveredIdentity;
|
|
983
990
|
return context.db.writeTransaction(() => {
|
|
984
991
|
const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [initial.id]);
|
|
985
992
|
if (row.status === "settled")
|
|
@@ -992,6 +999,9 @@ export function observeLifecycleInvocation(context, input) {
|
|
|
992
999
|
if (row.identity_json === identityJson) {
|
|
993
1000
|
return { eventKey: row.event_key, duplicate: true, ...resolved };
|
|
994
1001
|
}
|
|
1002
|
+
if (row.identity_json && JSON.stringify(JSON.parse(row.identity_json).slice(0, 5)) === JSON.stringify(JSON.parse(identityJson).slice(0, 5))) {
|
|
1003
|
+
throw new AppError("invocation_identity_mismatch", "The same native tool call changed its bound execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1004
|
+
}
|
|
995
1005
|
const eventKey = input.recordReceipt({ projectRoot: scope.projectRoot, runId: scope.runId, command: initial.command });
|
|
996
1006
|
// A native hook may execute from a provider worktree while the issued
|
|
997
1007
|
// lifecycle scope is anchored to the stable project root. Project
|
|
@@ -1019,7 +1029,7 @@ export function observedLifecycleNativeEvent(context, command, daemonId) {
|
|
|
1019
1029
|
return null;
|
|
1020
1030
|
const signature = (value) => {
|
|
1021
1031
|
const parsed = parseLifecycleCommand(value);
|
|
1022
|
-
const candidate = parsed.kind
|
|
1032
|
+
const candidate = parsed.kind !== "none" && parsed.supported ? parsed.invocation : continuationCommand(value);
|
|
1023
1033
|
if (!candidate)
|
|
1024
1034
|
return null;
|
|
1025
1035
|
return JSON.stringify([candidate.operation, candidate.args.positional,
|
|
@@ -1080,8 +1090,9 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
1080
1090
|
if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
|
|
1081
1091
|
return null;
|
|
1082
1092
|
const analysis = parseLifecycleCommand(command);
|
|
1083
|
-
if (analysis.kind
|
|
1093
|
+
if (analysis.kind === "none" || analysis.supported === false)
|
|
1084
1094
|
return null;
|
|
1095
|
+
const leafCommand = analysis.invocation.command;
|
|
1085
1096
|
// `issued` is a rendezvous, not authority: the CLI still blocks in
|
|
1086
1097
|
// awaitLifecycleInvocation until the native observer commits the receipt.
|
|
1087
1098
|
const matches = context.db.all("SELECT * FROM lifecycle_invocations WHERE status IN ('issued','observed','executing') ORDER BY rowid DESC")
|
|
@@ -1090,7 +1101,7 @@ export function observedLifecycleInvocation(context, command, daemonId) {
|
|
|
1090
1101
|
const scope = JSON.parse(row.scope_json);
|
|
1091
1102
|
if (daemonId && scope.daemonId !== daemonId)
|
|
1092
1103
|
return false;
|
|
1093
|
-
return fingerprint(invocation(canonicalManagedCommand(context,
|
|
1104
|
+
return fingerprint(invocation(canonicalManagedCommand(context, leafCommand, scope))) === row.fingerprint;
|
|
1094
1105
|
}
|
|
1095
1106
|
catch {
|
|
1096
1107
|
return false;
|
|
@@ -1348,6 +1359,16 @@ export async function awaitLifecycleInvocation(context, input) {
|
|
|
1348
1359
|
}
|
|
1349
1360
|
input.assertCurrent(JSON.parse(row.scope_json));
|
|
1350
1361
|
if (row.status === "observed") {
|
|
1362
|
+
if (row.identity_json) {
|
|
1363
|
+
// Receipt display text is bounded/redacted and may not be valid JSON.
|
|
1364
|
+
// Only the native admission's typed identity binds an execution route.
|
|
1365
|
+
const identity = JSON.parse(row.identity_json);
|
|
1366
|
+
const route = identity[5]?.executionCwd;
|
|
1367
|
+
if (identity.length > 5 && (typeof route !== "string" || !path.isAbsolute(route)))
|
|
1368
|
+
throw new AppError("invocation_directory_mismatch", "Bound lifecycle execution cwd is invalid", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
|
|
1369
|
+
if (typeof route === "string" && canonicalPath(process.cwd()) !== canonicalPath(route))
|
|
1370
|
+
throw new AppError("invocation_directory_mismatch", "Reached CLI cwd differs from the bound shell execution route", 1, { phase: "prepare", effect: "no_effect", recoverable: false, expected: route, actual: process.cwd(), actual_provenance: "cli_process_cwd" });
|
|
1371
|
+
}
|
|
1351
1372
|
const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
|
|
1352
1373
|
if (claimed.changes === 1)
|
|
1353
1374
|
return { eventKey: row.event_key, replay: false };
|
|
@@ -61,7 +61,12 @@ export function prepareManagedProcessRegistration(context, input) {
|
|
|
61
61
|
throw new AppError("validation", "Unique managed operation requires operationId", 2);
|
|
62
62
|
let metadataJson;
|
|
63
63
|
try {
|
|
64
|
-
|
|
64
|
+
const metadata = { ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) };
|
|
65
|
+
delete metadata.process_birth;
|
|
66
|
+
delete metadata.owner_birth;
|
|
67
|
+
if (input.ownerPid)
|
|
68
|
+
metadata.owner_birth = stableProcessBirth(input.ownerPid);
|
|
69
|
+
metadataJson = JSON.stringify(metadata);
|
|
65
70
|
}
|
|
66
71
|
catch {
|
|
67
72
|
throw new AppError("validation", "metadata must be JSON-serializable", 2);
|
|
@@ -86,7 +91,8 @@ export function registerManagedProcess(context, rawInput) {
|
|
|
86
91
|
if (!input.uniqueActiveOperation)
|
|
87
92
|
return insertManagedProcess(context, input, metadataJson);
|
|
88
93
|
const db = registry(context);
|
|
89
|
-
|
|
94
|
+
const observerProcessBirth = observer ? stableProcessBirth(input.pid) : null;
|
|
95
|
+
if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth, metadata_json: JSON.stringify({ process_birth: observerProcessBirth }) }))
|
|
90
96
|
throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
|
|
91
97
|
return db.writeTransaction(() => {
|
|
92
98
|
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
@@ -97,6 +103,7 @@ export function registerManagedProcess(context, rawInput) {
|
|
|
97
103
|
const metadata = JSON.parse(metadataJson);
|
|
98
104
|
if (input.processGroupId)
|
|
99
105
|
metadata.process_group_id = input.processGroupId;
|
|
106
|
+
metadata.process_birth = observerProcessBirth;
|
|
100
107
|
const now = context.now();
|
|
101
108
|
db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
|
|
102
109
|
result = requireProcess(db, result.id);
|
|
@@ -137,9 +144,11 @@ export function confirmManagedProcess(context, input) {
|
|
|
137
144
|
const metadata = parseMetadata(current.metadata_json);
|
|
138
145
|
if (input.processGroupId)
|
|
139
146
|
metadata.process_group_id = input.processGroupId;
|
|
147
|
+
metadata.process_birth = stableProcessBirth(input.pid);
|
|
140
148
|
if (input.ownerPid) {
|
|
141
149
|
metadata.owner_pid = input.ownerPid;
|
|
142
150
|
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
151
|
+
metadata.owner_birth = stableProcessBirth(input.ownerPid);
|
|
143
152
|
}
|
|
144
153
|
const result = db.run(`UPDATE managed_processes
|
|
145
154
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
@@ -216,6 +225,11 @@ export function processIsAlive(record) {
|
|
|
216
225
|
catch (error) {
|
|
217
226
|
return error.code !== "ESRCH";
|
|
218
227
|
}
|
|
228
|
+
const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
|
|
229
|
+
if (Object.hasOwn(metadata, "process_birth")) {
|
|
230
|
+
const birth = parseBirth(metadata.process_birth), observed = stableProcessBirth(record.pid);
|
|
231
|
+
return !birth || !observed || observed === birth;
|
|
232
|
+
}
|
|
219
233
|
const started = processStartedAt(record.pid);
|
|
220
234
|
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
221
235
|
}
|
|
@@ -223,6 +237,17 @@ export function processTreeIsAlive(record) {
|
|
|
223
237
|
if (processIsAlive(record))
|
|
224
238
|
return true;
|
|
225
239
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
240
|
+
const birth = parseBirth(parseMetadata(record.metadata_json).process_birth);
|
|
241
|
+
// POSIX cannot reuse a leader PID while its old process group survives.
|
|
242
|
+
if (process.platform !== "win32" && group === record.pid && record.pid && birth) {
|
|
243
|
+
try {
|
|
244
|
+
process.kill(record.pid, 0);
|
|
245
|
+
}
|
|
246
|
+
catch { /* A missing leader can still have living children. */ }
|
|
247
|
+
const observed = stableProcessBirth(record.pid);
|
|
248
|
+
if (observed && observed !== birth)
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
226
251
|
if (process.platform !== "win32" && typeof group === "number") {
|
|
227
252
|
try {
|
|
228
253
|
process.kill(-group, 0);
|
|
@@ -238,7 +263,7 @@ export function managedOwnerIsAlive(record) {
|
|
|
238
263
|
const metadata = parseMetadata(record.metadata_json);
|
|
239
264
|
if (typeof metadata.owner_pid !== "number")
|
|
240
265
|
return null;
|
|
241
|
-
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
266
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null, metadata_json: JSON.stringify({ process_birth: metadata.owner_birth }) });
|
|
242
267
|
}
|
|
243
268
|
/**
|
|
244
269
|
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
@@ -413,16 +438,27 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
|
|
|
413
438
|
return new Date(Date.parse(now) + leaseMs).toISOString();
|
|
414
439
|
}
|
|
415
440
|
export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
441
|
+
function stableProcessBirth(pid) {
|
|
442
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
|
|
443
|
+
const value = result.status === 0 ? result.stdout.trim() : "";
|
|
444
|
+
return value ? `ps-lstart-utc-c@1:${value}` : null;
|
|
445
|
+
}
|
|
446
|
+
function parseBirth(value) { return typeof value === "string" && value.startsWith("ps-lstart-utc-c@1:") && value.length > "ps-lstart-utc-c@1:".length ? value : null; }
|
|
416
447
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
417
448
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
418
449
|
function terminateOwnedProcess(record, signal) {
|
|
419
450
|
// Never signal a group merely because its former leader PID was once ours.
|
|
420
451
|
// PID reuse or a departed leader makes group ownership unprovable.
|
|
421
|
-
if (!record.pid || !record.pid_started_at
|
|
452
|
+
if (!record.pid || !record.pid_started_at)
|
|
422
453
|
return;
|
|
423
|
-
const
|
|
454
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
455
|
+
const birth = parseBirth(metadata.process_birth);
|
|
456
|
+
if (Object.hasOwn(metadata, "process_birth") ? !birth || stableProcessBirth(record.pid) !== birth : processStartedAt(record.pid) !== record.pid_started_at)
|
|
457
|
+
return;
|
|
458
|
+
const group = metadata.process_group_id;
|
|
459
|
+
// A non-leader PID does not establish ownership of its whole process group.
|
|
424
460
|
try {
|
|
425
|
-
process.kill(process.platform === "win32" ||
|
|
461
|
+
process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
|
|
426
462
|
}
|
|
427
463
|
catch { /* inspect again below */ }
|
|
428
464
|
}
|
|
@@ -11,7 +11,7 @@ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
|
|
|
11
11
|
import { runtimeHarness } from "./execution-policy.js";
|
|
12
12
|
import { harnessKey } from "./harness-config.js";
|
|
13
13
|
import { finishManagedProcess, managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
14
|
-
import { reconcileUnfinishedChecks } from "./code-checks.js";
|
|
14
|
+
import { reconcileUnfinishedChecks, terminalCheckSettled } from "./code-checks.js";
|
|
15
15
|
import { controlStatusReceipt } from "./run-control-receipt.js";
|
|
16
16
|
import { assertNativeCancellationReceipt, cancelControlledNativeSession, inspectControlledNativeSession } from "./native-session-control.js";
|
|
17
17
|
/** Durable projection only: status never probes a provider or acquires an owner. */
|
|
@@ -545,6 +545,12 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
|
|
|
545
545
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_lease_unsettled" };
|
|
546
546
|
if (!record.pid || !record.pid_started_at)
|
|
547
547
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_identity_unknown" };
|
|
548
|
+
// Code checks mark their process terminal only after proving its tree is
|
|
549
|
+
// empty, then publish the exact check receipt. A later PGID reuse cannot
|
|
550
|
+
// turn that already-settled check back into an owned live process.
|
|
551
|
+
const check = record.kind === "check" && record.project_id === project.id && record.run_id === input.runId ? terminalCheckSettled(context, record) : null;
|
|
552
|
+
if (check)
|
|
553
|
+
return { process_id: record.id, kind: record.kind, status: "completed", receipt: { source: "terminal_check_tree_settled", ...managedProcessBinding(record, home), check_id: record.check_id, finished_at: check.finished_at } };
|
|
548
554
|
if (!interrupt && processTreeIsAlive(record))
|
|
549
555
|
return { process_id: record.id, kind: record.kind, status: "pending", reason: "managed_process_running" };
|
|
550
556
|
if (boundary)
|
package/dist/services/runs.js
CHANGED
|
@@ -85,6 +85,7 @@ export function startFlowRun(context, input, prepared = prepareFlowRunStart(cont
|
|
|
85
85
|
run_id: runId,
|
|
86
86
|
short_id: shortId,
|
|
87
87
|
flow_kind: flowKind,
|
|
88
|
+
...(["vnext_specify", "vnext_protocolize"].includes(flowKind) ? { verification_matrix_contract: "dd-flow/verification-matrix@1" } : {}),
|
|
88
89
|
subject: {
|
|
89
90
|
id: subjectId,
|
|
90
91
|
title: subjectId
|
|
@@ -554,63 +555,75 @@ export function listFlowRuns(context, input) {
|
|
|
554
555
|
.map(flowRunSummary);
|
|
555
556
|
return { ok: true, project, runs };
|
|
556
557
|
}
|
|
557
|
-
export function prepareFlowRunStageAttachment(context, input) {
|
|
558
|
+
export function prepareFlowRunStageAttachment(context, input, archive = false) {
|
|
558
559
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
559
560
|
const run = resolveRun(context, project.id, input.runId);
|
|
560
|
-
assertRunMutationAllowed(context, project.id, run.id);
|
|
561
|
+
const generation = assertRunMutationAllowed(context, project.id, run.id);
|
|
561
562
|
const stage = requiredPlain(input.stage, "stage");
|
|
562
563
|
const dir = prepareStageDirectory(input.dir);
|
|
563
564
|
const index = authoritativeIndex(run);
|
|
564
565
|
const status = parseStageStatus(input.status);
|
|
565
566
|
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
566
567
|
assertVnextStageStart(run, index, stage, status, existing);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const runRoot = runArtifactRoot(run);
|
|
573
|
-
if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)))
|
|
574
|
-
archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
|
|
575
|
-
const now = context.now();
|
|
576
|
-
if (status === "running" && existing) {
|
|
577
|
-
archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
|
|
578
|
-
}
|
|
579
|
-
const stageRun = {
|
|
580
|
-
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
581
|
-
stage,
|
|
582
|
-
dir,
|
|
583
|
-
status,
|
|
584
|
-
...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
|
|
585
|
-
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
586
|
-
updated_at: now
|
|
587
|
-
};
|
|
588
|
-
if (status === "running") {
|
|
589
|
-
stageRun.started_at = now;
|
|
590
|
-
delete stageRun.completed_at;
|
|
591
|
-
stageRun.duration_ms = null;
|
|
592
|
-
stageRun.git = gitFacts(run.workspace_root);
|
|
593
|
-
stageRun.attempt = nextAttempt(existing);
|
|
568
|
+
if (archive) {
|
|
569
|
+
const runRoot = runArtifactRoot(run);
|
|
570
|
+
if (fs.existsSync(path.join(runRoot, `.archive-${dir}.json`)) || status === "running" && existing) {
|
|
571
|
+
archiveExistingStageAttempt(context, project.id, run.id, runRoot, dir);
|
|
572
|
+
}
|
|
594
573
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
574
|
+
return { project, run, index, stage, dir, status, existing, generation, attempt: status === "running" ? nextAttempt(existing) : existing?.attempt ?? "try-001", git: status === "running" ? gitFacts(run.workspace_root) : undefined };
|
|
575
|
+
}
|
|
576
|
+
export function attachFlowRunStage(context, input, prepared = prepareFlowRunStageAttachment(context, input, true)) {
|
|
577
|
+
// Filesystem archive and Git inspection are preparation, never writer work.
|
|
578
|
+
const { project, run, stage, dir, status, generation, git } = prepared;
|
|
579
|
+
const attached = context.db.writeTransaction(() => {
|
|
580
|
+
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
581
|
+
const latest = requireRunById(context, project.id, run.id);
|
|
582
|
+
const index = authoritativeIndex(latest);
|
|
583
|
+
const existing = index.stage_runs.find((item) => item.stage === stage);
|
|
584
|
+
if ((index.runtime_revision ?? 0) !== (prepared.index.runtime_revision ?? 0) || existing?.attempt !== prepared.existing?.attempt) {
|
|
585
|
+
throw new AppError("stage_revision_changed", "Stage changed during start preparation", 1, { run_id: run.id, stage });
|
|
586
|
+
}
|
|
587
|
+
assertVnextStageStart(latest, index, stage, status, existing);
|
|
588
|
+
const now = context.now();
|
|
589
|
+
const stageRun = {
|
|
590
|
+
...(existing ?? { order: index.stage_runs.length + 1 }),
|
|
591
|
+
stage,
|
|
592
|
+
dir,
|
|
593
|
+
status,
|
|
594
|
+
...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
|
|
595
|
+
...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
|
|
596
|
+
updated_at: now
|
|
597
|
+
};
|
|
598
|
+
if (status === "running") {
|
|
599
|
+
stageRun.started_at = now;
|
|
600
|
+
delete stageRun.completed_at;
|
|
601
|
+
stageRun.duration_ms = null;
|
|
602
|
+
if (git)
|
|
603
|
+
stageRun.git = git;
|
|
604
|
+
stageRun.attempt = prepared.attempt;
|
|
605
|
+
}
|
|
606
|
+
upsertStage(index, stageRun);
|
|
607
|
+
index.current_stage = stage;
|
|
608
|
+
index.attempts = index.stage_runs.map((item) => ({ stage: item.stage, attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1, root: item.dir, archive: null, status: item.status, started_at: item.started_at ?? now, finished_at: item.completed_at ?? null }));
|
|
609
|
+
index.updated_at = now;
|
|
610
|
+
persistRunState(context, project, run, index);
|
|
611
|
+
appendAudit(context, {
|
|
612
|
+
projectId: project.id,
|
|
613
|
+
eventType: "flow_run.stage_attached",
|
|
614
|
+
payload: { run_id: run.id, stage, dir, status }
|
|
615
|
+
});
|
|
616
|
+
appendRunTimeline(context, project.id, run.id, { at: now, type: "stage_attached", run_id: run.id, stage, status, attempt: stageRun.attempt ?? null });
|
|
617
|
+
context.db.afterCommit(() => reconcileRunStageReports(context, project.id, run.id, stage));
|
|
618
|
+
// A stage transition must not rewrite every Session in the RUN. A RUN may
|
|
619
|
+
// contain independent reviewer/worker sessions; their stage is set by the
|
|
620
|
+
// stage/work binding that actually owns that Session.
|
|
621
|
+
if (status === "running")
|
|
622
|
+
context.db.afterCommit(() => refreshRunSessionProjection(context, project.id, run.id));
|
|
623
|
+
return { stageRun, index };
|
|
624
|
+
}, "stage.attach");
|
|
612
625
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
613
|
-
return { ok: true, run: flowRunSummary(updatedRun), stage_run: stageRun, index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
626
|
+
return { ok: true, run: flowRunSummary(updatedRun), stage_run: attached.stageRun, index: attached.index, flow_guidance: guidanceForRun(context, updatedRun, attached.index) };
|
|
614
627
|
}
|
|
615
628
|
/** Keep synchronous stage settlement atomic; files are projections, SQLite is authoritative. */
|
|
616
629
|
export function withStageSettlement(context, operation) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createContext } from "../runtime/context.js";
|
|
4
5
|
import { AppError } from "../shared/errors.js";
|
|
5
6
|
import { runHarnessAdapter } from "./harness-adapter.js";
|
|
6
|
-
import { managedLeaseReleased, managedProcessBinding, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
7
|
+
import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
8
|
+
import { terminalCheckSettled } from "./code-checks.js";
|
|
7
9
|
import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
|
|
8
10
|
import { retainedDaemonOperations } from "./run-control.js";
|
|
9
11
|
import { historicalDaemonSettlement } from "./native-daemon-history.js";
|
|
@@ -80,10 +82,29 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
|
|
|
80
82
|
const metadata = JSON.parse(record.metadata_json);
|
|
81
83
|
// RUN control owns active controllers/checks. Adopt only its already-finished
|
|
82
84
|
// physical registrations here; death alone is not a completed RUN operation.
|
|
85
|
+
const terminalCheck = record.kind === "check" && metadata.role === "check" && record.check_id && record.project_id && record.run_id
|
|
86
|
+
? (() => {
|
|
87
|
+
const home = managedProcessRuntimeHome(record, context);
|
|
88
|
+
if (!home)
|
|
89
|
+
return null;
|
|
90
|
+
let reader;
|
|
91
|
+
try {
|
|
92
|
+
reader = createContext({ ...context.env, DD_FLOW_HOME: home }, "read_existing");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return terminalCheckSettled(reader, record);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
reader.db.close?.();
|
|
102
|
+
}
|
|
103
|
+
})() : null;
|
|
83
104
|
if ((record.kind === "run-controller" && metadata.role === "controller" && record.operation_id
|
|
84
105
|
|| record.kind === "check" && metadata.role === "check" && record.check_id)
|
|
85
106
|
&& record.project_id && record.run_id && ["stopped", "failed"].includes(record.state)
|
|
86
|
-
&& record.pid && record.pid_started_at && !processTreeIsAlive(record)) {
|
|
107
|
+
&& record.pid && record.pid_started_at && (terminalCheck || !processTreeIsAlive(record))) {
|
|
87
108
|
const operationId = record.operation_id ?? record.check_id;
|
|
88
109
|
nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
|
|
89
110
|
continue;
|
|
@@ -39,8 +39,10 @@ export function acceptStagePacket(context, input) {
|
|
|
39
39
|
context.db.afterCommit(() => publishStagePacket(context, input.projectId, input.runId, input.stage, input.prepared.file));
|
|
40
40
|
}
|
|
41
41
|
export function publishStagePacket(context, projectId, runId, stage, file) {
|
|
42
|
+
assertPublicationPath(context, projectId, runId, file);
|
|
42
43
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
44
|
withProjectionLock(`${file}.publish-lock`, () => {
|
|
45
|
+
assertPublicationPath(context, projectId, runId, file);
|
|
44
46
|
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
45
47
|
const index = run ? JSON.parse(run.index_json) : null;
|
|
46
48
|
const currentAttempt = index?.stage_runs?.find((item) => item.stage === stage)?.attempt;
|
|
@@ -76,7 +78,9 @@ export function acceptStageReport(context, input) {
|
|
|
76
78
|
}
|
|
77
79
|
/** Replays the latest committed receipt, never an obsolete attempt. */
|
|
78
80
|
export function publishStageReport(context, projectId, runId, stage, root) {
|
|
81
|
+
assertPublicationPath(context, projectId, runId, root);
|
|
79
82
|
withProjectionLock(`${root}.report-publish-lock`, () => {
|
|
83
|
+
assertPublicationPath(context, projectId, runId, root);
|
|
80
84
|
const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
81
85
|
const index = run ? JSON.parse(run.index_json) : null;
|
|
82
86
|
const currentAttempt = index?.stage_runs?.find((item) => item.stage === stage)?.attempt;
|
|
@@ -87,6 +91,7 @@ export function publishStageReport(context, projectId, runId, stage, root) {
|
|
|
87
91
|
fs.mkdirSync(root, { recursive: true });
|
|
88
92
|
for (const [name, bytes] of Object.entries(receipt.bytes)) {
|
|
89
93
|
const file = path.join(root, `stage-report.${name === "json" ? "json" : name === "markdown" ? "md" : "html"}`);
|
|
94
|
+
assertPublicationPath(context, projectId, runId, file);
|
|
90
95
|
try {
|
|
91
96
|
fs.writeFileSync(file, bytes, { flag: "wx" });
|
|
92
97
|
}
|
|
@@ -99,6 +104,20 @@ export function publishStageReport(context, projectId, runId, stage, root) {
|
|
|
99
104
|
}
|
|
100
105
|
});
|
|
101
106
|
}
|
|
107
|
+
/** Recheck the actual destination after preparation: a missing leaf may have
|
|
108
|
+
* an existing symlink ancestor, and retained packet bytes never authorize it. */
|
|
109
|
+
function assertPublicationPath(context, projectId, runId, file) {
|
|
110
|
+
const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
111
|
+
const root = run?.run_root && path.resolve(run.run_root), destination = path.resolve(file);
|
|
112
|
+
if (!root || !destination.startsWith(`${root}${path.sep}`))
|
|
113
|
+
throw new AppError("publication_pending", "Stage publication escaped its RUN", 1, { effect: "committed", run_id: runId, file });
|
|
114
|
+
let ancestor = destination;
|
|
115
|
+
while (!fs.existsSync(ancestor) && ancestor !== root)
|
|
116
|
+
ancestor = path.dirname(ancestor);
|
|
117
|
+
const actual = fs.realpathSync(ancestor), realRoot = fs.realpathSync(root);
|
|
118
|
+
if (actual !== realRoot && !actual.startsWith(`${realRoot}${path.sep}`))
|
|
119
|
+
throw new AppError("publication_pending", "Stage publication escaped its RUN through a symlink", 1, { effect: "committed", run_id: runId, file });
|
|
120
|
+
}
|
|
102
121
|
/** Admission repair for predecessor reports; the stage being retried is archived separately. */
|
|
103
122
|
export function reconcileRunStageReports(context, projectId, runId, exceptStage) {
|
|
104
123
|
const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_report' ORDER BY id DESC", [projectId]);
|