@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/help.js +2 -0
- package/dist/cli/run-cli.js +15 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
- package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
- package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
- package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
- package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
- package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
- package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
- package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
- package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/services/code-checks.js +120 -19
- package/dist/services/controller-fanout.js +11 -0
- package/dist/services/engines.js +40 -9
- package/dist/services/eval-snapshots.js +30 -47
- package/dist/services/harness-adapter.js +12 -6
- package/dist/services/lifecycle-invocations.js +12 -8
- package/dist/services/native-session-control.js +6 -0
- package/dist/services/projects.js +6 -2
- package/dist/services/recovery-observation-budget.js +9 -1
- package/dist/services/run-control-worker.js +19 -0
- package/dist/services/run-control.js +87 -13
- package/dist/services/run-controller-adapter.js +134 -7
- package/dist/services/run-controller.js +11 -3
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/runs.js +1 -11
- package/dist/services/schema-validation.js +1 -1
- package/dist/services/stage-lifecycle.js +14 -2
- package/dist/services/usage.js +20 -87
- package/dist/services/vnext-code-review.js +35 -27
- package/dist/services/vnext-code.js +62 -26
- package/dist/services/vnext-merge.js +8 -8
- package/dist/services/vnext-plan-review.js +16 -16
- package/dist/services/vnext-plan.js +51 -11
- package/dist/services/work-registry.js +48 -27
- package/dist/shared/errors.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Canonical physical-session tool accounting. Native decoding lives below;
|
|
4
|
+
* consumers never have to inspect provider envelopes or aggregate tokens. */
|
|
5
|
+
export class ToolObservations {
|
|
6
|
+
constructor() { this.calls = new Map(); this.gaps = new Set(); }
|
|
7
|
+
add(event) {
|
|
8
|
+
if (!event) return;
|
|
9
|
+
if (event.reason) { this.gaps.add(event.reason); return; }
|
|
10
|
+
const { harness_id, session_id } = event.session ?? {};
|
|
11
|
+
if (!harness_id || !session_id || !event.call_id) { this.gaps.add("tool_identity_missing"); return; }
|
|
12
|
+
const key = JSON.stringify([harness_id, session_id, event.epoch ?? "", event.call_id]);
|
|
13
|
+
const old = this.calls.get(key);
|
|
14
|
+
// Replay and late starts cannot undo a terminal outcome. Conflicting
|
|
15
|
+
// terminal facts remain visible rather than silently choosing success.
|
|
16
|
+
const terminal = value => ["completed", "failed"].includes(value);
|
|
17
|
+
if (old && terminal(old.status) && terminal(event.status) && old.status !== event.status) this.gaps.add("conflicting_tool_outcomes");
|
|
18
|
+
this.calls.set(key, { ...old, ...event, name: event.name ?? old?.name ?? "unknown",
|
|
19
|
+
status: old?.status === "failed" || event.status === "failed" ? "failed" : terminal(old?.status) ? old.status : event.status ?? old?.status ?? "unknown" });
|
|
20
|
+
}
|
|
21
|
+
summary(sessionId, cursor) {
|
|
22
|
+
const by_tool = {}, sessions = new Map(), reasons = new Set(this.gaps); let total = 0, failures = 0, pending = 0;
|
|
23
|
+
for (const [key, value] of this.calls) {
|
|
24
|
+
if (sessionId !== undefined && value.session.session_id !== sessionId || cursor?.has(key)) continue;
|
|
25
|
+
const id = JSON.stringify(value.session);
|
|
26
|
+
if (value.name === "unknown") reasons.add("tool_start_missing");
|
|
27
|
+
const session = sessions.get(id) ?? { session: value.session, parent_session: value.parent_session ?? null, total: 0, failures: 0, pending: 0, by_tool: {} };
|
|
28
|
+
total++; session.total++; by_tool[value.name] = (by_tool[value.name] ?? 0) + 1; session.by_tool[value.name] = (session.by_tool[value.name] ?? 0) + 1;
|
|
29
|
+
if (value.status === "failed") { failures++; session.failures++; }
|
|
30
|
+
if (!["completed", "failed"].includes(value.status)) { pending++; session.pending++; }
|
|
31
|
+
sessions.set(id, session);
|
|
32
|
+
}
|
|
33
|
+
return { schema_id: "dd-flow/tool-summary@1", scope: "physical_sessions", measurement: "observed_invocations", origin: "native_tool_events", aggregation: cursor ? "delta" : "cumulative", total, failures, pending, by_tool,
|
|
34
|
+
sessions: [...sessions.values()], completeness: reasons.size ? "partial" : "complete", outcome_completeness: pending ? "partial" : "complete", reasons: [...reasons] };
|
|
35
|
+
}
|
|
36
|
+
cursor() { return new Set(this.calls.keys()); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** For adapters whose raw transcript is not portable, retain only normalized
|
|
40
|
+
* tool facts in the existing owned journal. Repeated snapshots are idempotent. */
|
|
41
|
+
export function journalToolObservations(file, events, previous) {
|
|
42
|
+
const batch = new ToolObservations();
|
|
43
|
+
for (const event of events) batch.add(event);
|
|
44
|
+
const records = [...batch.calls.values(), ...[...batch.gaps].map(reason => ({ reason }))];
|
|
45
|
+
for (const event of records) {
|
|
46
|
+
const key = JSON.stringify([event.session, event.epoch, event.call_id, event.reason]);
|
|
47
|
+
const encoded = JSON.stringify(event);
|
|
48
|
+
if (previous.get(key) === encoded) continue;
|
|
49
|
+
fs.appendFileSync(file, `${JSON.stringify({ kind: "tool_observation", schema_id: "dd-flow/tool-observation@1", observed_at: new Date().toISOString(), event })}\n`, { mode: 0o600 });
|
|
50
|
+
previous.set(key, encoded);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const identity = (harness_id, session_id) => ({ harness_id, session_id });
|
|
55
|
+
const status = value => ["error", "failed", "ERROR"].includes(value) ? "failed" : ["completed", "success", "DONE"].includes(value) ? "completed" : "running";
|
|
56
|
+
export function zcodePhysicalSession(update, rootSessionId) {
|
|
57
|
+
const native = update?._meta?.zcodeRuntime ?? {};
|
|
58
|
+
if (native.source === "subagent" && !native.childSessionId) return null;
|
|
59
|
+
return native.childSessionId ?? rootSessionId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function acpToolObservation(message, { harness = "zcode-acp", rootSessionId, adapterSessionId } = {}) {
|
|
63
|
+
if (!["session/update", "_x.ai/session_notification"].includes(message?.method)) return null;
|
|
64
|
+
const update = message.params?.update;
|
|
65
|
+
if (!["tool_call", "tool_call_update"].includes(update?.sessionUpdate)) return null;
|
|
66
|
+
const transport = message.params?.sessionId;
|
|
67
|
+
if (adapterSessionId && transport !== adapterSessionId) return null;
|
|
68
|
+
const root = rootSessionId ?? transport;
|
|
69
|
+
const session = harness === "zcode-acp" ? zcodePhysicalSession(update, root) : root;
|
|
70
|
+
if (!session || !update.toolCallId) return { reason: "tool_identity_missing" };
|
|
71
|
+
return { session: identity(harness, session), parent_session: session !== root ? identity(harness, root) : null,
|
|
72
|
+
transport_session_id: transport, call_id: update.toolCallId, name: update._meta?.["x.ai/tool"]?.name ?? update._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0], status: status(update.status) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function droidToolObservations(record, sessionId) {
|
|
76
|
+
return (Array.isArray(record.message?.content) ? record.message.content : []).flatMap(block => {
|
|
77
|
+
if (block.type === "tool_use") return [{ session: identity("droid-cli", sessionId), call_id: block.id, name: block.name, status: "running" }];
|
|
78
|
+
if (block.type === "tool_result") return [{ session: identity("droid-cli", sessionId), call_id: block.tool_use_id, status: block.is_error ? "failed" : "completed" }];
|
|
79
|
+
return [];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function codexToolObservation(record, sessionId) {
|
|
84
|
+
if (record.type !== "response_item") return null;
|
|
85
|
+
const p = record.payload ?? {};
|
|
86
|
+
if (["function_call", "custom_tool_call"].includes(p.type)) return { session: identity("codex-desktop", sessionId), call_id: p.call_id, name: p.name, status: "running" };
|
|
87
|
+
if (["function_call_output", "custom_tool_call_output"].includes(p.type)) {
|
|
88
|
+
return { session: identity("codex-desktop", sessionId), call_id: p.call_id, status: containsFailure(p) ? "failed" : "completed" };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function containsFailure(value) {
|
|
94
|
+
if (Array.isArray(value)) return value.some(containsFailure);
|
|
95
|
+
if (typeof value === "string") {
|
|
96
|
+
if (/(?:^|\n)exit=(?:[1-9]\d*)\b|Process exited with code [1-9]\d*/.test(value)) return true;
|
|
97
|
+
if (/^[{[]/.test(value.trim())) { try { return containsFailure(JSON.parse(value)); } catch {} }
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (!value || typeof value !== "object") return false;
|
|
101
|
+
if (value.ok === false || value.is_error === true || value.isError === true || value.error === true || typeof value.error === "string") return true;
|
|
102
|
+
if (typeof value.exit_code === "number" && value.exit_code !== 0) return true;
|
|
103
|
+
return Object.values(value).some(containsFailure);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function openCodeToolObservation(part, sessionId, messageId) {
|
|
107
|
+
if (part.type !== "tool") return null;
|
|
108
|
+
return { session: identity("opencode-server", part.sessionID ?? sessionId), call_id: part.callID ?? part.id,
|
|
109
|
+
epoch: messageId, name: part.tool, status: status(part.state?.status) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function agyToolObservation(step, rootSessionId) {
|
|
113
|
+
if (step.step_type !== "tool") return null;
|
|
114
|
+
const sessionId = step.conversation_id ?? rootSessionId;
|
|
115
|
+
return { session: identity("antigravity-cli", sessionId), parent_session: sessionId !== rootSessionId ? identity("antigravity-cli", rootSessionId) : null,
|
|
116
|
+
call_id: step.step_id ?? step.stepId ?? step.id ?? step.tool_call_id ?? step.toolCallId ?? (Number.isInteger(step.step_index) ? String(step.step_index) : null),
|
|
117
|
+
name: step.tool_name ?? step.tool_info?.name, status: step.tool_info?.error ? "failed" : status(step.state) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read-only replay, shared by accounting and offline diagnosis. A journal
|
|
121
|
+
* carries physical identities; an ACP connection identity never crosses out. */
|
|
122
|
+
export function replayToolJournal(file, options, reducer = new ToolObservations()) {
|
|
123
|
+
let bytes;
|
|
124
|
+
try { bytes = fs.readFileSync(file, "utf8"); } catch (error) { reducer.gaps.add(error.code === "ENOENT" ? "journal_missing" : "journal_unreadable"); return reducer; }
|
|
125
|
+
const lines = bytes.split("\n"); let session = options.rootSessionId, transport = options.adapterSessionId;
|
|
126
|
+
for (const [index, line] of lines.entries()) {
|
|
127
|
+
if (!line.trim()) continue;
|
|
128
|
+
let e; try { e = JSON.parse(line); } catch { reducer.gaps.add(index === lines.length - 1 ? "journal_partial_tail" : "journal_invalid_record"); continue; }
|
|
129
|
+
const add = event => { if (event) reducer.add({ ...event, source: { journal: file, line: index + 1 }, observed_at: e.observed_at ?? e.timestamp ?? e.at ?? e.ts ?? null }); };
|
|
130
|
+
if (e.kind === "tool_observation" && e.schema_id === "dd-flow/tool-observation@1") { add(e.event); continue; }
|
|
131
|
+
if (e.kind === "notification_failure") reducer.gaps.add("notification_failure");
|
|
132
|
+
const m = e.payload ?? e;
|
|
133
|
+
if (options.harness === "zcode-acp" || options.harness === "grok-acp") {
|
|
134
|
+
if (m.method === "zcode/session/allocated" && m.params?.provider_session_id === session) transport = m.params.adapter_session_id;
|
|
135
|
+
if (e.kind === "inbound" || m.method) add(acpToolObservation(m, { ...options, rootSessionId: session, adapterSessionId: transport }));
|
|
136
|
+
} else if (options.harness === "codex-desktop") {
|
|
137
|
+
if (e.type === "session_meta" && e.payload?.id !== session) { reducer.gaps.add("session_identity_mismatch"); break; }
|
|
138
|
+
add(codexToolObservation(e, session));
|
|
139
|
+
} else if (options.harness === "droid-cli") {
|
|
140
|
+
if (e.type === "session_start" && e.id !== session) { reducer.gaps.add("session_identity_mismatch"); break; }
|
|
141
|
+
for (const event of droidToolObservations(e, session)) add(event);
|
|
142
|
+
} else if (options.harness === "antigravity-cli") {
|
|
143
|
+
const native = m.event ?? e;
|
|
144
|
+
if (native.event === "step_update") add(agyToolObservation(native.step_update ?? {}, session));
|
|
145
|
+
} else if (options.harness === "opencode-server") {
|
|
146
|
+
// Native SSE message.part.updated, retained in the adapter journal.
|
|
147
|
+
const event = m.event ?? m;
|
|
148
|
+
const part = event.properties?.part;
|
|
149
|
+
if (part) add(openCodeToolObservation(part, session, part.messageID));
|
|
150
|
+
} else reducer.gaps.add("tool_observation_unsupported");
|
|
151
|
+
}
|
|
152
|
+
return reducer;
|
|
153
|
+
}
|
|
@@ -12,7 +12,14 @@ const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "co
|
|
|
12
12
|
// auditable attempt. A second caller must not mistake that small handoff window
|
|
13
13
|
// for a dead check and overwrite its outcome.
|
|
14
14
|
const receiptStartingGraceMs = 30_000;
|
|
15
|
-
export function codeExecutionEnvironment(workspaceRoot) {
|
|
15
|
+
export function codeExecutionEnvironment(workspaceRoot) {
|
|
16
|
+
const suffix = crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex").slice(0, 12);
|
|
17
|
+
// Managed checks retain the launcher's executable resolution. A login shell
|
|
18
|
+
// or NODE_PATH can silently replace it, including with another architecture.
|
|
19
|
+
const environment = { ...process.env, DD_FLOW_LOCAL_DATABASE_SUFFIX: suffix };
|
|
20
|
+
delete environment.NODE_PATH;
|
|
21
|
+
return environment;
|
|
22
|
+
}
|
|
16
23
|
export function workspaceOperationId(workspaceRoot) { return `workspace:${crypto.createHash("sha256").update(path.resolve(workspaceRoot)).digest("hex")}`; }
|
|
17
24
|
export function readCodeCheckProfile(workspaceRoot) {
|
|
18
25
|
const file = path.join(workspaceRoot, profileRelativePath);
|
|
@@ -144,9 +151,13 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
|
|
|
144
151
|
for (const receiptId of retryIds) {
|
|
145
152
|
const predecessor = validateFailedCheckRetry(context, { projectId: input.projectId, runId: input.runId, receiptId, declarations: checks, reason: input.retryReason });
|
|
146
153
|
const snapshot = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
147
|
-
const group = groups.find(candidate =>
|
|
154
|
+
const group = groups.find(candidate => candidate.declarations.some(check => check.run_at === predecessor.gate)
|
|
155
|
+
&& checkInputHash(input.workspaceRoot, { ...candidate, gate: predecessor.gate }, profileHash, workspaceInputFingerprint(input.workspaceRoot, snapshot.files, candidate.inputs, [input.runHome])) === predecessor.input_hash);
|
|
148
156
|
if (!group || retries.has(group))
|
|
149
157
|
throw new AppError("retry_check_invalid", "Retry must match exactly one current execution contract", 2, { check_receipt_id: receiptId });
|
|
158
|
+
// Preserve the identity of a pre-deduplication failed execution when an
|
|
159
|
+
// upgraded RUN retries it; the new receipt still proves every grouped ref.
|
|
160
|
+
group.gate = predecessor.gate;
|
|
150
161
|
retries.set(group, receiptId);
|
|
151
162
|
}
|
|
152
163
|
for (let index = 0; index < groups.length; index += 1) {
|
|
@@ -321,7 +332,11 @@ function reserveOrReuseReceipt(context, input) {
|
|
|
321
332
|
throw new AppError("retry_check_invalid", "The requested retry already has a terminal outcome; choose its failed receipt for another retry", 2, { check_receipt_id: successor.id, retry_of_receipt_id: retryId, status: successor.status });
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
|
-
|
|
335
|
+
// Only success is reusable evidence. A new finish attempt must execute a
|
|
336
|
+
// failed check again: repairing dependencies/services need not change any
|
|
337
|
+
// tracked input. Lifecycle invocation replay owns command idempotency;
|
|
338
|
+
// the running-receipt guard above still prevents concurrent execution.
|
|
339
|
+
if (prior?.status === "passed" && !retryIds.has(prior.id) && input.group.reusable && checkReceiptEvidenceValid(prior)) {
|
|
325
340
|
context.db.exec("COMMIT");
|
|
326
341
|
return { receipt: prior };
|
|
327
342
|
}
|
|
@@ -392,19 +407,105 @@ function resolvedReceiptArtifacts(receiptPath, artifacts) {
|
|
|
392
407
|
return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], inputs: original.inputs ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
|
|
393
408
|
}
|
|
394
409
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
function
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
410
|
+
/** One NUL-aware source of truth for paths differing from HEAD. Rename/copy
|
|
411
|
+
* records carry a second path; retain both because the old side is a deletion. */
|
|
412
|
+
export function workspaceChangedPaths(root) {
|
|
413
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
414
|
+
// A workspace can be intentionally gitless. Keep the same conservative
|
|
415
|
+
// file inventory used for fingerprints instead of making result validation
|
|
416
|
+
// fail before the work contract can report its actual error.
|
|
417
|
+
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
418
|
+
return listFiles(root);
|
|
419
|
+
if (status.status !== 0 || status.error)
|
|
420
|
+
throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
421
|
+
const records = status.stdout.split("\0");
|
|
422
|
+
const paths = [];
|
|
423
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
424
|
+
const record = records[index];
|
|
425
|
+
if (!record)
|
|
426
|
+
continue;
|
|
427
|
+
if (record.length < 4 || record[2] !== " ")
|
|
428
|
+
throw new AppError("workspace_status_invalid", "Git returned an invalid porcelain-v1 record", 1, { workspace_root: root, record });
|
|
429
|
+
paths.push(normalizeWorkspacePath(root, record.slice(3)));
|
|
430
|
+
if (record[0] === "R" || record[0] === "C" || record[1] === "R" || record[1] === "C") {
|
|
431
|
+
const prior = records[++index];
|
|
432
|
+
if (!prior)
|
|
433
|
+
throw new AppError("workspace_status_invalid", "Git rename/copy record is missing its second path", 1, { workspace_root: root, record });
|
|
434
|
+
paths.push(normalizeWorkspacePath(root, prior));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return [...new Set(paths)].sort();
|
|
438
|
+
}
|
|
439
|
+
/** Ignored machine state cannot be reported as a product change. Tracked paths
|
|
440
|
+
* remain eligible regardless of their directory name. */
|
|
441
|
+
export function ignoredWorkspacePaths(root, paths) {
|
|
442
|
+
const normalized = [...new Set(paths.map((item) => normalizeWorkspacePath(root, item)))];
|
|
443
|
+
if (!normalized.length)
|
|
444
|
+
return [];
|
|
445
|
+
const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
|
|
446
|
+
// Projects may intentionally run without Git (the flow supports a local
|
|
447
|
+
// workspace identity independently from version control). In that case
|
|
448
|
+
// there is no ignore database to consult; a declared path is a product
|
|
449
|
+
// path, not an infrastructure path. Other Git failures remain fail-closed.
|
|
450
|
+
if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
|
|
451
|
+
return [];
|
|
452
|
+
if (![0, 1].includes(status.status ?? -1) || status.error)
|
|
453
|
+
throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
|
|
454
|
+
return [...new Set(status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)))].sort();
|
|
455
|
+
}
|
|
456
|
+
export function normalizeWorkspacePath(root, value) {
|
|
457
|
+
if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
|
|
458
|
+
throw new AppError("workspace_path_invalid", "Workspace path must be a non-empty relative path", 2, { path: value });
|
|
459
|
+
const resolvedRoot = path.resolve(root);
|
|
460
|
+
const resolved = path.resolve(resolvedRoot, value);
|
|
461
|
+
const relative = path.relative(resolvedRoot, resolved);
|
|
462
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
463
|
+
throw new AppError("workspace_path_invalid", "Workspace path escapes its root", 2, { path: value, workspace_root: root });
|
|
464
|
+
return relative.split(path.sep).join("/");
|
|
465
|
+
}
|
|
466
|
+
function deduplicate(checks, commands) {
|
|
467
|
+
const groups = new Map();
|
|
468
|
+
checks.forEach((check, index) => {
|
|
469
|
+
const artifacts = [...(check.required_artifacts ?? [])].sort(), ports = [...(check.ports ?? [])].sort(), inputs = normalizedInputs(check.inputs ?? []);
|
|
470
|
+
const reusable = check.reuse === "deterministic";
|
|
471
|
+
// Stage labels identify obligations, not different processes in this gate.
|
|
472
|
+
// Cross-invocation reuse still requires the explicit deterministic contract.
|
|
473
|
+
const key = JSON.stringify([commands[index], artifacts, ports, inputs, reusable]);
|
|
474
|
+
const current = groups.get(key);
|
|
475
|
+
if (current)
|
|
476
|
+
current.declarations.push(check);
|
|
477
|
+
else
|
|
478
|
+
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports, inputs, reusable });
|
|
479
|
+
});
|
|
480
|
+
return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
|
|
481
|
+
}
|
|
482
|
+
function workspaceState(root, ignoredRoots = []) {
|
|
483
|
+
const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
484
|
+
const ignored = ignoredRoots.map(entry => path.resolve(entry));
|
|
485
|
+
const listing = [...new Set(git.status === 0 ? git.stdout.split("\0").filter(Boolean) : listFiles(root))].sort();
|
|
486
|
+
const files = new Map(), hash = crypto.createHash("sha256");
|
|
487
|
+
for (const relative of listing) {
|
|
488
|
+
const file = path.resolve(root, relative);
|
|
489
|
+
if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
|
|
490
|
+
continue;
|
|
491
|
+
// Deleted tracked paths are absent content, both before and after commit.
|
|
492
|
+
// Do not mask other IO errors as an unchanged workspace.
|
|
493
|
+
let stat;
|
|
494
|
+
try {
|
|
495
|
+
stat = fs.lstatSync(file);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (error.code === "ENOENT")
|
|
499
|
+
continue;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
const content = stat.isSymbolicLink() ? Buffer.from(`symlink\0${fs.readlinkSync(file)}`) : stat.isFile() ? fs.readFileSync(file) : Buffer.from("<directory>");
|
|
503
|
+
const digest = crypto.createHash("sha256").update(content).digest("hex");
|
|
504
|
+
files.set(relative, digest);
|
|
505
|
+
hash.update(relative).update("\0").update(digest).update("\0");
|
|
506
|
+
}
|
|
507
|
+
return { fingerprint: hash.digest("hex"), files };
|
|
508
|
+
}
|
|
408
509
|
function workspaceInputFingerprint(root, files, inputs, ignoredRoots) {
|
|
409
510
|
const selected = new Map(inputs.length === 0 || inputs.includes(".") ? files : []);
|
|
410
511
|
for (const [file, digest] of explicitInputFiles(root, inputs.filter(input => input !== "."), ignoredRoots))
|
|
@@ -522,8 +623,8 @@ function explicitInputFiles(root, inputs, ignoredRoots) {
|
|
|
522
623
|
visit(path.resolve(root, input), input, new Set());
|
|
523
624
|
return [...result.entries()];
|
|
524
625
|
}
|
|
525
|
-
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
|
|
526
|
-
function bindReceipt(receipt, group) { return { ...receipt, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
|
|
626
|
+
function checkInputHash(workspaceRoot, group, profileHash, inputFingerprint) { return crypto.createHash("sha256").update(JSON.stringify({ workspace_root: path.resolve(workspaceRoot), gate: group.reusable ? null : group.gate, command: group.command, required_artifacts: group.requiredArtifacts, ports: group.ports, inputs: group.inputs, profile_hash: profileHash, environment: environmentFingerprint(workspaceRoot), input_fingerprint: inputFingerprint })).digest("hex"); }
|
|
627
|
+
function bindReceipt(receipt, group) { return { ...receipt, gate: group.gate, declaration_id: group.declarations[0].id, check_refs: group.declarations.map(checkRef), required_artifacts: group.requiredArtifacts, inputs: group.inputs }; }
|
|
527
628
|
function latestApplicableReceipt(context, input) { const candidates = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((receipt) => receipt.input_hash === input.inputHash && receipt.profile_hash === input.profileHash); return candidates.at(-1) ?? null; }
|
|
528
629
|
export function checkReceiptEvidenceValid(receipt) { if (receipt.status !== "passed" || receipt.exit_code !== 0 || receipt.before_fingerprint !== receipt.after_fingerprint || !fs.existsSync(receipt.completion_path) || readCompletion(receipt.completion_path)?.exit_code !== 0)
|
|
529
630
|
return false; const stored = readReceipt(receipt.receipt_path); if (!stored || stored.status !== "passed" || stored.input_hash !== receipt.input_hash || stored.profile_hash !== receipt.profile_hash)
|
|
@@ -644,7 +745,7 @@ function checkShellScript() {
|
|
|
644
745
|
// The actual command cannot run before PID ownership and admission persist.
|
|
645
746
|
'IFS= read -r admission && [ "$admission" = admitted ] || exit 125',
|
|
646
747
|
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
647
|
-
'/bin/sh -
|
|
748
|
+
'/bin/sh -c "$DD_FLOW_CHECK_COMMAND"',
|
|
648
749
|
"code=$?",
|
|
649
750
|
'if [ -n "$completion" ]; then temporary="$completion.tmp.$$"; printf \'{"exit_code":%s,"finished_at":"%s"}\\n\' "$code" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temporary" && mv "$temporary" "$completion"; fi',
|
|
650
751
|
'exit "$code"'
|
|
@@ -3,6 +3,17 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
|
|
|
3
3
|
import { getFlowRunVariables } from "./runs.js";
|
|
4
4
|
import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
|
|
5
5
|
import { resolveExecutionPolicy } from "./execution-policy.js";
|
|
6
|
+
/** Stage entry acknowledges the packet before the controller issues child commands. */
|
|
7
|
+
export function controllerStageEntryPrompt(stage, command, responseFile) {
|
|
8
|
+
return [
|
|
9
|
+
`Start the assigned ${stage} Stage now. Your first tool call must be this exact standalone command:`,
|
|
10
|
+
command,
|
|
11
|
+
`Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
|
|
12
|
+
"If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
|
|
13
|
+
"Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
|
|
14
|
+
"Copy runtime-issued lifecycle commands verbatim as standalone calls. Never generate an invocation ID, substitute a shell variable for it, or borrow one from another command. If a required command is missing or rejected without a returned retry command, report the exact error and end the Turn so the controller can handle it."
|
|
15
|
+
].join("\n\n");
|
|
16
|
+
}
|
|
6
17
|
/** The shared controller owns this former eval-only Work-graph decision. */
|
|
7
18
|
export async function nextControllerFanout(context, input) {
|
|
8
19
|
const children = input.children.filter(child => child.status !== "settled_by_root");
|
package/dist/services/engines.js
CHANGED
|
@@ -117,7 +117,7 @@ export function installCurrentEngine(context, input = {}) {
|
|
|
117
117
|
}
|
|
118
118
|
export function listEngines(context) {
|
|
119
119
|
const root = engineStoreRoot(context.ddFlowHome);
|
|
120
|
-
const engines = readInstalledManifests(root).map(manifestSummary);
|
|
120
|
+
const engines = readInstalledManifests(root).map(manifest => manifestSummary(manifest));
|
|
121
121
|
return { ok: true, schema_id: "dd-flow/engine-list@1", engine_store: root, engines };
|
|
122
122
|
}
|
|
123
123
|
export function engineInfo(context, input) {
|
|
@@ -138,11 +138,10 @@ export function resolveEngine(context, input = {}) {
|
|
|
138
138
|
export function doctorEngines(context, input = {}) {
|
|
139
139
|
const root = engineStoreRoot(context.ddFlowHome);
|
|
140
140
|
const installed = readInstalledManifests(root);
|
|
141
|
-
const checks = installed.map(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}));
|
|
141
|
+
const checks = installed.map(manifest => {
|
|
142
|
+
const diagnostics = manifestDiagnostics(manifest);
|
|
143
|
+
return { engine: manifestSummary(manifest, diagnostics), status: diagnostics.length ? "corrupt" : "ok", diagnostics };
|
|
144
|
+
});
|
|
146
145
|
const classification = input.classification ?? classifyCliOperation(["engine", "doctor"], input.env ?? context.env);
|
|
147
146
|
const selection = selectEngine(context, input, {}, classification);
|
|
148
147
|
return {
|
|
@@ -254,6 +253,38 @@ export function bindCurrentEngineToRun(context, input) {
|
|
|
254
253
|
};
|
|
255
254
|
return writeRunEngineBinding(path.join(input.runRoot, "engine-binding.json"), binding).binding;
|
|
256
255
|
}
|
|
256
|
+
/** Forks are a new execution authority. The source binding stays immutable;
|
|
257
|
+
* this replaces only the copied binding after proving the selected engine can
|
|
258
|
+
* read the copied RUN. */
|
|
259
|
+
export function upgradeForkedRunEngine(context, input) {
|
|
260
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
261
|
+
const located = locateRunRoot(context.db, projectRoot, input.runId);
|
|
262
|
+
if (!located)
|
|
263
|
+
throw new AppError("not_found", `Forked RUN is not registered: ${input.runId}`, 1);
|
|
264
|
+
const manifest = requireBoundManifest(context.ddFlowHome, {
|
|
265
|
+
packageName: getCliBuildInfo().package_name,
|
|
266
|
+
packageVersion: input.engineVersion,
|
|
267
|
+
...(input.integrityChecksum ? { integrityChecksum: input.integrityChecksum } : {})
|
|
268
|
+
});
|
|
269
|
+
const requested = engineIdentity(manifest);
|
|
270
|
+
readRunEngineBinding(located.binding_path);
|
|
271
|
+
const lineage = path.join(located.run_root, "fork-lineage.json");
|
|
272
|
+
if (!fs.existsSync(lineage))
|
|
273
|
+
throw new AppError("fork_lineage_missing", "Engine upgrade is permitted only for a derived RUN", 1, { run_id: located.run_id });
|
|
274
|
+
const probeArgs = ["run", "status", located.run_id, "--project-root", projectRoot, "--json"];
|
|
275
|
+
const probe = spawnSync(process.execPath, [resolveManifestEntrypoint(manifest), ...probeArgs], {
|
|
276
|
+
cwd: manifest.snapshot_root,
|
|
277
|
+
env: { ...process.env, ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_ENGINE_HOME: manifest.snapshot_root },
|
|
278
|
+
encoding: "utf8", timeout: 30_000
|
|
279
|
+
});
|
|
280
|
+
if (probe.status !== 0)
|
|
281
|
+
throw new AppError("fork_engine_probe_failed", "Selected engine cannot read the forked RUN", 1, { run_id: located.run_id, engine: manifestSummary(manifest), stderr: probe.stderr.slice(0, 2000) });
|
|
282
|
+
const binding = { schema_id: runEngineBindingSchemaId, run_id: located.run_id, project_root: projectRoot, bound_at: context.now(), source: "fork_upgrade", reason: requiredText(input.reason, "reason"), engine: requested, probe: { status: "passed", command: `dd-flow ${probeArgs.join(" ")}` } };
|
|
283
|
+
const temporary = `${located.binding_path}.fork-${process.pid}-${Date.now()}.tmp`;
|
|
284
|
+
fs.writeFileSync(temporary, `${JSON.stringify(binding, null, 2)}\n`);
|
|
285
|
+
fs.renameSync(temporary, located.binding_path);
|
|
286
|
+
return binding;
|
|
287
|
+
}
|
|
257
288
|
function boundRunEngineForArgs(context, args, projectRoot) {
|
|
258
289
|
const runId = runIdForArgs(args);
|
|
259
290
|
if (!runId || !projectRoot)
|
|
@@ -847,7 +878,7 @@ function manifestDiagnostics(manifest, options = {}) {
|
|
|
847
878
|
}
|
|
848
879
|
return diagnostics;
|
|
849
880
|
}
|
|
850
|
-
function manifestSummary(manifest) {
|
|
881
|
+
function manifestSummary(manifest, diagnostics = manifestDiagnostics(manifest, { checkEntrypoint: false })) {
|
|
851
882
|
return {
|
|
852
883
|
schema_id: manifest.schema_id,
|
|
853
884
|
package_name: manifest.package_name,
|
|
@@ -859,8 +890,8 @@ function manifestSummary(manifest) {
|
|
|
859
890
|
snapshot_root: manifest.snapshot_root,
|
|
860
891
|
resolved_entrypoint: safeResolveManifestEntrypoint(manifest),
|
|
861
892
|
installed_at: manifest.installed_at,
|
|
862
|
-
healthy:
|
|
863
|
-
diagnostics
|
|
893
|
+
healthy: diagnostics.length === 0,
|
|
894
|
+
diagnostics
|
|
864
895
|
};
|
|
865
896
|
}
|
|
866
897
|
function resolveManifestEntrypoint(manifest) {
|
|
@@ -11,7 +11,6 @@ import { registerProject, requireProjectByRoot } from "./projects.js";
|
|
|
11
11
|
import { freezeRunExecutionRouting, getFlowRunStatus, importFlowRunExecutionProfile, startFlowRun } from "./runs.js";
|
|
12
12
|
import { loadExecutionRoutingInput, resolveExecutionPolicy } from "./execution-policy.js";
|
|
13
13
|
import { readVnextFlowDefinition } from "./vnext-specify.js";
|
|
14
|
-
import { assertRunWorkResultArtifacts, runWorkResultArtifactIssues } from "./work-registry.js";
|
|
15
14
|
import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
|
|
16
15
|
import { resourceHome } from "./managed-processes.js";
|
|
17
16
|
import { codexHomeSharedEntries } from "./hooks.js";
|
|
@@ -104,8 +103,6 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
104
103
|
}
|
|
105
104
|
if (!recovery)
|
|
106
105
|
assertDedicatedHome(context, project.id, input.runId);
|
|
107
|
-
if (!input.incomplete)
|
|
108
|
-
assertRunWorkResultArtifacts(context, project.id, input.runId);
|
|
109
106
|
if (recovery) {
|
|
110
107
|
const guard = context.db.get("SELECT status, recovery_id FROM run_recovery_guards WHERE project_id = ? AND run_id = ? AND recovery_id = ?", [project.id, input.runId, input.recoveryId]);
|
|
111
108
|
if (!guard || guard.status !== "sealed")
|
|
@@ -181,28 +178,17 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
181
178
|
});
|
|
182
179
|
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
183
180
|
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (recovery)
|
|
181
|
+
// Accepted Work results are captured in SQLite. Their file representations
|
|
182
|
+
// are retained evidence, not a second authority or a checkpoint gate.
|
|
183
|
+
if (recovery) {
|
|
184
|
+
const copiedContext = createContext({ ...context.env, DD_FLOW_HOME: path.join(output, "runtime") }, "read_existing");
|
|
185
|
+
try {
|
|
190
186
|
captureRecoveryAuxiliary(context, copiedContext, { projectId: project.id, runId: input.runId, output });
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
|
196
|
-
return path.join(output, payload, relative);
|
|
197
|
-
}
|
|
198
|
-
throw new AppError("snapshot_receipt_path_invalid", "Work receipt is outside captured payloads", 1, { result_path: file });
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
finally {
|
|
202
|
-
copiedContext.db.close?.();
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
copiedContext.db.close?.();
|
|
190
|
+
}
|
|
203
191
|
}
|
|
204
|
-
if (!input.incomplete && receiptIssues.length)
|
|
205
|
-
throw new AppError("snapshot_work_receipt_inconsistent", "Copied Work result does not match its authoritative receipt", 1, { issues: receiptIssues });
|
|
206
192
|
const manifest = {
|
|
207
193
|
schema_id: "dd-flow/eval-run-snapshot@5",
|
|
208
194
|
run_id: input.runId,
|
|
@@ -213,7 +199,6 @@ function captureEvalRunSnapshot(context, input) {
|
|
|
213
199
|
stage_entry: input.incomplete || recovery || input.candidate ? null : input.stageEntry,
|
|
214
200
|
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : recovery ? { recovery_id: input.recoveryId, consistency: "sealed_writer_barrier_required", active_child_work_count: activeChildren, restorable: "same-runtime-or-explicit-restore" } : {}),
|
|
215
201
|
created_at: context.now(),
|
|
216
|
-
work_result_integrity: { valid: receiptIssues.length === 0, issues: receiptIssues },
|
|
217
202
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
218
203
|
project_sha256: treeHash(path.join(output, "project")),
|
|
219
204
|
project_git: projectGit,
|
|
@@ -477,30 +462,21 @@ function rebaseRuntime(context, oldRoot, newRoot, oldWorkspace, newWorkspace, ol
|
|
|
477
462
|
finally {
|
|
478
463
|
context.db.exec("PRAGMA foreign_keys = ON");
|
|
479
464
|
}
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
465
|
+
// Only operational database paths move. Imported packets, results and
|
|
466
|
+
// journals retain their historical bytes, including interrupted JSONL tails.
|
|
467
|
+
// The frozen workspace route is an operational receipt consumed by later
|
|
468
|
+
// stages, so its relocated path is the sole structured-file exception.
|
|
469
|
+
rewriteOperationalRouteReceipts(context.ddFlowHome, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome);
|
|
484
470
|
}
|
|
485
|
-
function
|
|
486
|
-
const visit = (
|
|
487
|
-
for (const entry of fs.readdirSync(
|
|
488
|
-
const file = path.join(
|
|
489
|
-
|
|
490
|
-
if (/^execution-routing(?:\.[a-f0-9]{64})?\.input\.json$/.test(entry.name))
|
|
491
|
-
continue;
|
|
492
|
-
// DD_FLOW_HOME also contains the restored checkout and installed tools.
|
|
493
|
-
// They are project/tool bytes, not flow records; parsing every *.json
|
|
494
|
-
// there breaks on valid JSON-with-comments such as tsconfig files.
|
|
495
|
-
if (entry.isDirectory() && ![".git", "checkouts", "engines", "node_modules", "tools", "snapshot-imports"].includes(entry.name))
|
|
471
|
+
function rewriteOperationalRouteReceipts(root, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome) {
|
|
472
|
+
const visit = (directory) => {
|
|
473
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
474
|
+
const file = path.join(directory, entry.name);
|
|
475
|
+
if (entry.isDirectory())
|
|
496
476
|
visit(file);
|
|
497
|
-
else if (entry.isFile() && entry.name
|
|
477
|
+
else if (entry.isFile() && entry.name === "workspace-route.json") {
|
|
498
478
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
502
|
-
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map((line) => JSON.stringify(rebaseStructuredValue(JSON.parse(line), oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome)));
|
|
503
|
-
fs.writeFileSync(file, `${lines.join("\n")}\n`);
|
|
479
|
+
fs.writeFileSync(file, `${JSON.stringify(rebaseStructuredValue(value, oldRoot, newRoot, oldWorkspace, newWorkspace, oldHome, newHome), null, 2)}\n`);
|
|
504
480
|
}
|
|
505
481
|
}
|
|
506
482
|
};
|
|
@@ -583,7 +559,9 @@ function restoreProjectGit(snapshot, source, projectRoot) {
|
|
|
583
559
|
}
|
|
584
560
|
const mergeStateFiles = ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "ORIG_HEAD", "AUTO_MERGE"];
|
|
585
561
|
function indexEntries(root) {
|
|
586
|
-
const entries = gitBytes(root, ["ls-files", "--stage", "-z"
|
|
562
|
+
const entries = Buffer.from(gitBytes(root, ["ls-files", "--stage", "-z"])
|
|
563
|
+
.toString("utf8").split("\0").filter(entry => entry && snapshotEnvironmentAllowed(entry.slice(entry.indexOf("\t") + 1)))
|
|
564
|
+
.map(entry => `${entry}\0`).join(""));
|
|
587
565
|
const merge = {};
|
|
588
566
|
for (const name of mergeStateFiles) {
|
|
589
567
|
const file = git(root, ["rev-parse", "--git-path", name]);
|
|
@@ -710,10 +688,15 @@ function runGit(root, args) {
|
|
|
710
688
|
throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
|
|
711
689
|
}
|
|
712
690
|
function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
|
|
691
|
+
function snapshotEnvironmentAllowed(source) {
|
|
692
|
+
const name = path.basename(source);
|
|
693
|
+
// Public configuration template, not a local environment/credential file.
|
|
694
|
+
return name === ".env.example" || (name !== ".env" && !name.startsWith(".env."));
|
|
695
|
+
}
|
|
713
696
|
function snapshotPayloadAllowed(source) {
|
|
714
697
|
const name = path.basename(source);
|
|
715
698
|
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
716
|
-
&&
|
|
699
|
+
&& snapshotEnvironmentAllowed(source);
|
|
717
700
|
}
|
|
718
701
|
function runtimePayloadAllowed(source) {
|
|
719
702
|
return snapshotPayloadAllowed(source) && !["auth.json", "auth.bk", "credentials.json", ".credentials.json", "credentials", ".credentials", "id_rsa", "id_ed25519", "engines", "checkouts"].includes(path.basename(source));
|