@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.1ff37038366e
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/README.md +88 -2
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +72 -3
- package/dist/bridge/bridge.service.js +1563 -152
- package/dist/bridge/bridge.types.d.ts +46 -1
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.help.js +2 -2
- package/dist/cli/cli.service.d.ts +14 -6
- package/dist/cli/cli.service.js +75 -16
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +4 -0
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
- package/dist/runtime/attempt-process-supervisor.js +67 -20
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +62 -8
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +191 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +8 -1
- package/dist/runtime/host-listener-inspector.js +121 -60
- package/dist/runtime/host-runtime-contract.d.ts +12 -1
- package/dist/runtime/host-runtime-contract.js +168 -55
- package/dist/runtime/host-runtime-lease.d.ts +92 -8
- package/dist/runtime/host-runtime-lease.js +1141 -114
- package/dist/runtime/host-runtime-promotion.d.ts +12 -3
- package/dist/runtime/host-runtime-promotion.js +59 -12
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +28 -11
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.js +4 -1
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.d.ts +2 -1
- package/dist/sandbox/repository-lock.js +17 -8
- package/dist/sandbox/runtime.d.ts +6 -1
- package/dist/sandbox/runtime.js +132 -103
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/sandbox/workspace.service.js +2 -0
- package/dist/session/managed-attempt-tools.d.ts +17 -1
- package/dist/session/managed-attempt-tools.js +485 -31
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +224 -22
- package/dist/session/phase-completion.d.ts +11 -1
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +30 -7
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.js +17 -18
- package/dist/session/phase-completion.runtime.d.ts +3 -1
- package/dist/session/phase-completion.runtime.js +126 -17
- package/dist/session/phase-completion.schemas.js +49 -2
- package/dist/session/phase-completion.validation.d.ts +1 -0
- package/dist/session/phase-completion.validation.js +111 -21
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +661 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
- package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
- package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
- package/dist/session/provider-cli/provider-cli.process.js +422 -22
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +8 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +43 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
- package/dist/session/provider-cli/provider-cli.service.js +771 -68
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
- package/dist/session/provider-cli/provider-output-state.js +361 -17
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.js +434 -9
- package/dist/session/session.service.js +145 -23
- package/dist/session/session.types.d.ts +147 -2
- package/dist/session/session.types.js +587 -7
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +18 -0
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +57 -9
- package/dist/shared/process/process.d.ts +2 -0
- package/dist/shared/process/process.js +12 -7
- package/dist/shared/provider-capabilities.d.ts +3 -0
- package/dist/shared/provider-capabilities.js +85 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +39 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +41 -9
- package/dist/worktree/dependency-warmer.js +497 -22
- package/dist/worktree/index.d.ts +1 -1
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree.service.d.ts +7 -0
- package/dist/worktree/worktree.service.js +253 -21
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +7 -5
- package/runtime/windows-job-supervisor.ps1 +380 -14
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { GitClient } from "../shared/git/git.js";
|
|
5
|
+
import { isSubPath } from "../shared/path/path.js";
|
|
6
|
+
import { stableStringify } from "../session/phase-completion.helpers.js";
|
|
7
|
+
const MAX_FILES = 128;
|
|
8
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const hash = (body) => createHash("sha256").update(body).digest("hex");
|
|
11
|
+
const fail = (reason) => new Error(`qa_handoff_invalid: ${reason}`);
|
|
12
|
+
const segment = (value) => {
|
|
13
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(value))
|
|
14
|
+
throw fail("invalid reference identity");
|
|
15
|
+
return value;
|
|
16
|
+
};
|
|
17
|
+
export function storyQaReferenceRoot(storyId, runId) {
|
|
18
|
+
return `.forge/work-items/${segment(storyId)}/qa-inputs/${segment(runId)}`;
|
|
19
|
+
}
|
|
20
|
+
// All inputs are reference-only. No phase verdict is created, promoted or replaced here.
|
|
21
|
+
export async function materializeStoryQaReferences(worktreePath, request, git = new GitClient()) {
|
|
22
|
+
const story = request.storyQa;
|
|
23
|
+
const bundle = story?.references;
|
|
24
|
+
if (!bundle)
|
|
25
|
+
return;
|
|
26
|
+
if (!request.referenceRunId || bundle.schemaVersion !== 1 || bundle.storyWorkItemId !== request.workItem.id
|
|
27
|
+
|| bundle.cycleRunId !== request.referenceCycleRunId || bundle.storyWorkItemId !== request.ownerId
|
|
28
|
+
|| bundle.storyWorkItemId !== story.storyWorkItemId || bundle.repoId !== request.repo.id
|
|
29
|
+
|| bundle.projectId !== request.workItem.projectId || bundle.effectiveAmendmentHash !== request.effectivePlanAmendmentHash
|
|
30
|
+
|| bundle.planId !== story.planId || bundle.planContentHash !== story.planContentHash || story.worktreeKey !== request.worktreeKey
|
|
31
|
+
|| !/^[a-f0-9]{64}$/i.test(bundle.effectiveAmendmentHash))
|
|
32
|
+
throw fail("reference owner/plan identity mismatch");
|
|
33
|
+
const relativeRoot = storyQaReferenceRoot(bundle.storyWorkItemId, request.referenceRunId);
|
|
34
|
+
const root = path.resolve(worktreePath, relativeRoot);
|
|
35
|
+
await safePath(worktreePath, root);
|
|
36
|
+
const files = [...bundle.files];
|
|
37
|
+
validateFiles(files);
|
|
38
|
+
const checkpoint = await captureCheckpoint(worktreePath, bundle, git);
|
|
39
|
+
if (checkpoint)
|
|
40
|
+
files.push(checkpoint);
|
|
41
|
+
validateFiles(files);
|
|
42
|
+
const manifest = JSON.stringify({ ...bundle, files: files.map(({ body: _body, ...file }) => file),
|
|
43
|
+
notice: "Reference input only. Historical PASS and repair checkpoint status are unchanged. Fresh QA is still required." }, null, 2) + "\n";
|
|
44
|
+
// Prepare a sibling directory and publish by rename; never leave a partially written manifest.
|
|
45
|
+
await fs.mkdir(path.dirname(root), { recursive: true });
|
|
46
|
+
await safePath(worktreePath, path.dirname(root));
|
|
47
|
+
const existing = await fs.stat(root).catch(error => { if (error.code === "ENOENT")
|
|
48
|
+
return null; throw error; });
|
|
49
|
+
if (existing) {
|
|
50
|
+
// A resumed cycle may reuse only its exact immutable reference snapshot.
|
|
51
|
+
await safePath(worktreePath, path.join(root, "manifest.json"));
|
|
52
|
+
const previous = JSON.parse(await fs.readFile(path.join(root, "manifest.json"), "utf8"));
|
|
53
|
+
const current = JSON.parse(manifest);
|
|
54
|
+
delete previous.capturedAt;
|
|
55
|
+
delete current.capturedAt;
|
|
56
|
+
if (stableStringify(previous) !== stableStringify(current))
|
|
57
|
+
throw fail("reference snapshot changed for this run");
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
await safePath(worktreePath, path.join(root, file.path));
|
|
60
|
+
if (hash(await fs.readFile(path.join(root, file.path))) !== file.contentHash.toLowerCase())
|
|
61
|
+
throw fail("existing reference hash mismatch");
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const staging = `${root}.tmp-${randomUUID()}`;
|
|
66
|
+
await fs.mkdir(staging);
|
|
67
|
+
try {
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const target = path.join(staging, file.path);
|
|
70
|
+
await safePath(worktreePath, target);
|
|
71
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
72
|
+
await fs.writeFile(target, file.body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
73
|
+
}
|
|
74
|
+
await fs.writeFile(path.join(staging, "manifest.json"), manifest, { flag: "wx", mode: 0o600 });
|
|
75
|
+
await safePath(worktreePath, root);
|
|
76
|
+
await fs.rename(staging, root);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function validateFiles(files) {
|
|
83
|
+
if (files.length === 0 || files.length > MAX_FILES)
|
|
84
|
+
throw fail("reference file count exceeds bounds");
|
|
85
|
+
const paths = new Set();
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
if (!/^(original|effective|children|amendments|checkpoint)\/[a-zA-Z0-9_./-]+$/.test(file.path)
|
|
89
|
+
|| file.path.split("/").some(part => !part || part === "." || part === ".." || part.endsWith(".")))
|
|
90
|
+
throw fail("unsafe reference path");
|
|
91
|
+
const key = file.path.toLowerCase();
|
|
92
|
+
if (paths.has(key))
|
|
93
|
+
throw fail("duplicate reference path");
|
|
94
|
+
paths.add(key);
|
|
95
|
+
const bytes = Buffer.byteLength(file.body, "utf8");
|
|
96
|
+
total += bytes;
|
|
97
|
+
if (bytes > MAX_FILE_BYTES || total > MAX_TOTAL_BYTES)
|
|
98
|
+
throw fail("reference size exceeds bounds");
|
|
99
|
+
if (!/^[a-f0-9]{64}$/i.test(file.contentHash) || hash(file.body) !== file.contentHash.toLowerCase())
|
|
100
|
+
throw fail("reference content hash mismatch");
|
|
101
|
+
if (!file.provenance.trim())
|
|
102
|
+
throw fail("reference provenance missing");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function captureCheckpoint(worktreePath, bundle, git) {
|
|
106
|
+
const storyId = bundle.storyWorkItemId;
|
|
107
|
+
const originalPath = path.join(worktreePath, ".forge", "work-items", segment(storyId), "artifacts", "comment-repair.handoff.json");
|
|
108
|
+
await safePath(worktreePath, originalPath);
|
|
109
|
+
let sourcePath = originalPath;
|
|
110
|
+
let archivedManifestPath;
|
|
111
|
+
let stat = await fs.stat(sourcePath).catch(error => { if (error.code === "ENOENT")
|
|
112
|
+
return null; throw error; });
|
|
113
|
+
if (!stat) {
|
|
114
|
+
// A previous QA already cleared outputs. A preserved input is not a new output:
|
|
115
|
+
// validate its immutable snapshot before deciding whether it still describes this tree.
|
|
116
|
+
const historyRoot = path.join(worktreePath, ".forge", "work-items", segment(storyId), "qa-inputs");
|
|
117
|
+
await safePath(worktreePath, historyRoot);
|
|
118
|
+
const entries = await fs.readdir(historyRoot, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT")
|
|
119
|
+
return []; throw error; });
|
|
120
|
+
const candidates = [];
|
|
121
|
+
if (entries.length > MAX_FILES)
|
|
122
|
+
throw fail("checkpoint reference history exceeds bounds");
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!entry.isDirectory() || entry.name.includes(".tmp-"))
|
|
125
|
+
continue;
|
|
126
|
+
const candidate = path.join(historyRoot, segment(entry.name), "checkpoint", "comment-repair.handoff.json");
|
|
127
|
+
await safePath(worktreePath, candidate);
|
|
128
|
+
const candidateStat = await fs.stat(candidate).catch(error => { if (error.code === "ENOENT")
|
|
129
|
+
return null; throw error; });
|
|
130
|
+
if (candidateStat)
|
|
131
|
+
candidates.push({ path: candidate, modified: candidateStat.mtimeMs });
|
|
132
|
+
}
|
|
133
|
+
candidates.sort((a, b) => b.modified - a.modified);
|
|
134
|
+
if (!candidates[0])
|
|
135
|
+
return null;
|
|
136
|
+
sourcePath = candidates[0].path;
|
|
137
|
+
archivedManifestPath = path.join(path.dirname(path.dirname(sourcePath)), "manifest.json");
|
|
138
|
+
stat = await fs.stat(sourcePath);
|
|
139
|
+
}
|
|
140
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
|
|
141
|
+
throw fail("checkpoint size exceeds bounds");
|
|
142
|
+
const body = await fs.readFile(sourcePath, "utf8");
|
|
143
|
+
const checkpoint = JSON.parse(body);
|
|
144
|
+
const recordedHash = checkpoint.content_hash;
|
|
145
|
+
const withoutHash = { ...checkpoint };
|
|
146
|
+
delete withoutHash.content_hash;
|
|
147
|
+
if (typeof recordedHash !== "string" || hash(stableStringify(withoutHash)) !== recordedHash.toLowerCase())
|
|
148
|
+
throw fail("checkpoint content hash mismatch");
|
|
149
|
+
const legacyOwner = checkpoint.owner_id === undefined && checkpoint.owner_type === undefined;
|
|
150
|
+
if (checkpoint.phase !== "comment_repair" || checkpoint.work_item_id !== storyId
|
|
151
|
+
|| (!legacyOwner && (checkpoint.owner_id !== storyId || !["work_item", "story"].includes(String(checkpoint.owner_type))))
|
|
152
|
+
|| !["pass", "needs_rework", "blocked"].includes(String(checkpoint.status))
|
|
153
|
+
|| !Array.isArray(checkpoint.changed_files))
|
|
154
|
+
throw fail("checkpoint provenance mismatch");
|
|
155
|
+
// Ordinary repair artifacts describe paths/summaries but do not certify current bytes.
|
|
156
|
+
// Preserve them only as historical claims; extended checkpoints must pass every exact-tree fence.
|
|
157
|
+
const extended = checkpoint.attempt_dispatch_id !== undefined
|
|
158
|
+
|| checkpoint.changed_files.some(value => value && typeof value === "object" && "sha256" in value);
|
|
159
|
+
const ordinaryProvenance = `historical-local-repair-claim:status=${checkpoint.status};not-current-tree-validation;not-authoritative-validation`;
|
|
160
|
+
const exactProvenance = `preserved-local-agent-claim:${checkpoint.attempt_dispatch_id}:HEAD=${checkpoint.repair_head_sha}:status=${checkpoint.status};not-authoritative-validation`;
|
|
161
|
+
const historicalProvenance = `historical-local-repair-claim:${checkpoint.attempt_dispatch_id}:recordedHEAD=${checkpoint.repair_head_sha}:status=${checkpoint.status};not-current-tree-validation;not-authoritative-validation`;
|
|
162
|
+
const archivedProvenance = archivedManifestPath
|
|
163
|
+
? await validateArchivedCheckpoint(worktreePath, archivedManifestPath, bundle, body, extended ? [exactProvenance, historicalProvenance] : [ordinaryProvenance])
|
|
164
|
+
: undefined;
|
|
165
|
+
if (!extended)
|
|
166
|
+
return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body), provenance: ordinaryProvenance };
|
|
167
|
+
if (typeof checkpoint.attempt_dispatch_id !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(checkpoint.attempt_dispatch_id))
|
|
168
|
+
throw fail("checkpoint attempt identity missing");
|
|
169
|
+
if (typeof checkpoint.repair_head_sha !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(checkpoint.repair_head_sha))
|
|
170
|
+
throw fail("checkpoint HEAD invalid");
|
|
171
|
+
if (checkpoint.changed_files.length > 512)
|
|
172
|
+
throw fail("checkpoint file count exceeds bounds");
|
|
173
|
+
const recorded = new Set();
|
|
174
|
+
for (const value of checkpoint.changed_files) {
|
|
175
|
+
if (!value || typeof value !== "object")
|
|
176
|
+
throw fail("invalid checkpoint file");
|
|
177
|
+
const file = value;
|
|
178
|
+
if (typeof file.path !== "string" || file.path.includes("\\") || file.path.split("/").some(part => !part || part === "." || part === "..")
|
|
179
|
+
|| path.isAbsolute(file.path) || file.path.includes(":") || [...file.path].some(character => character.charCodeAt(0) <= 31)
|
|
180
|
+
|| typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256))
|
|
181
|
+
throw fail("unsafe checkpoint file");
|
|
182
|
+
const filePath = file.path;
|
|
183
|
+
if ([...recorded].some(value => value.toLowerCase() === filePath.toLowerCase()))
|
|
184
|
+
throw fail("duplicate checkpoint file");
|
|
185
|
+
recorded.add(filePath);
|
|
186
|
+
}
|
|
187
|
+
const head = (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
|
|
188
|
+
if (archivedProvenance && (checkpoint.repair_head_sha !== head || archivedProvenance === historicalProvenance)) {
|
|
189
|
+
// Publication (and later repair) may legitimately advance the owned branch. This
|
|
190
|
+
// snapshot remains an unchanged historical claim, NEVER proof of the new tree.
|
|
191
|
+
// Do not apply this path to fresh outputs or infer a PASS/publication receipt.
|
|
192
|
+
await git.run(worktreePath, ["merge-base", "--is-ancestor", checkpoint.repair_head_sha, head])
|
|
193
|
+
.catch(() => { throw fail("checkpoint history is not an ancestor of the prepared HEAD"); });
|
|
194
|
+
if ((await git.run(worktreePath, ["rev-parse", "HEAD"])).trim() !== head)
|
|
195
|
+
throw fail("checkpoint HEAD changed during verification");
|
|
196
|
+
return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body), provenance: historicalProvenance };
|
|
197
|
+
}
|
|
198
|
+
if (checkpoint.repair_head_sha !== head)
|
|
199
|
+
throw fail("checkpoint HEAD changed");
|
|
200
|
+
for (const value of checkpoint.changed_files) {
|
|
201
|
+
const file = value;
|
|
202
|
+
const target = path.resolve(worktreePath, file.path);
|
|
203
|
+
await safePath(worktreePath, target);
|
|
204
|
+
const fileStat = await fs.stat(target);
|
|
205
|
+
if (!fileStat.isFile() || fileStat.size > 32 * 1024 * 1024 || hash(await fs.readFile(target)) !== file.sha256.toLowerCase())
|
|
206
|
+
throw fail("checkpoint changed-file hash mismatch");
|
|
207
|
+
}
|
|
208
|
+
const tracked = (await git.run(worktreePath, ["diff", "--name-only", "-z", "HEAD"])).split("\0").filter(Boolean);
|
|
209
|
+
const untracked = (await git.run(worktreePath, ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0")
|
|
210
|
+
.filter(file => file && !/^\.forge\/(work-items|stories)\//.test(file));
|
|
211
|
+
const changed = new Set([...tracked, ...untracked]);
|
|
212
|
+
if (recorded.size !== changed.size || [...changed].some(file => !recorded.has(file)))
|
|
213
|
+
throw fail("checkpoint does not cover the complete prepared diff");
|
|
214
|
+
if ((await git.run(worktreePath, ["rev-parse", "HEAD"])).trim() !== head)
|
|
215
|
+
throw fail("checkpoint HEAD changed during verification");
|
|
216
|
+
return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body),
|
|
217
|
+
provenance: exactProvenance };
|
|
218
|
+
}
|
|
219
|
+
async function validateArchivedCheckpoint(worktreePath, manifestPath, bundle, body, allowedProvenance) {
|
|
220
|
+
await safePath(worktreePath, manifestPath);
|
|
221
|
+
const stat = await fs.stat(manifestPath).catch(() => { throw fail("checkpoint reference manifest missing"); });
|
|
222
|
+
if (!stat.isFile() || stat.size > MAX_TOTAL_BYTES)
|
|
223
|
+
throw fail("checkpoint reference manifest exceeds bounds");
|
|
224
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
225
|
+
if (manifest?.schemaVersion !== 1 || manifest.storyWorkItemId !== bundle.storyWorkItemId
|
|
226
|
+
|| manifest.projectId !== bundle.projectId || manifest.repoId !== bundle.repoId
|
|
227
|
+
|| manifest.planId !== bundle.planId || manifest.planContentHash !== bundle.planContentHash
|
|
228
|
+
|| manifest.effectiveAmendmentHash !== bundle.effectiveAmendmentHash
|
|
229
|
+
|| typeof manifest.cycleRunId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(manifest.cycleRunId)
|
|
230
|
+
|| !Array.isArray(manifest.files) || manifest.files.length > MAX_FILES)
|
|
231
|
+
throw fail("checkpoint reference identity mismatch");
|
|
232
|
+
const files = manifest.files.filter((file) => typeof file?.path === "string" && file.path.toLowerCase() === "checkpoint/comment-repair.handoff.json");
|
|
233
|
+
if (files.length !== 1 || files[0].path !== "checkpoint/comment-repair.handoff.json"
|
|
234
|
+
|| files[0].contentHash !== hash(body) || !allowedProvenance.includes(files[0].provenance))
|
|
235
|
+
throw fail("checkpoint reference integrity mismatch");
|
|
236
|
+
return files[0].provenance;
|
|
237
|
+
}
|
|
238
|
+
async function safePath(root, target) {
|
|
239
|
+
if (!isSubPath(root, target))
|
|
240
|
+
throw fail("reference path escapes worktree");
|
|
241
|
+
let current = path.resolve(root);
|
|
242
|
+
for (const part of path.relative(current, target).split(path.sep)) {
|
|
243
|
+
if (!part)
|
|
244
|
+
continue;
|
|
245
|
+
current = path.join(current, part);
|
|
246
|
+
const stat = await fs.lstat(current).catch(error => { if (error.code === "ENOENT")
|
|
247
|
+
return null; throw error; });
|
|
248
|
+
if (!stat)
|
|
249
|
+
return;
|
|
250
|
+
if (stat.isSymbolicLink())
|
|
251
|
+
throw fail("reference path traverses symlink");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -7,8 +7,10 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
7
7
|
constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
|
|
8
8
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
9
9
|
private trackedForgePaths;
|
|
10
|
+
private registeredManagedWorktreePath;
|
|
10
11
|
private repoCreationCoordinationPath;
|
|
11
12
|
release(handle: WorktreeHandle): Promise<void>;
|
|
13
|
+
private restoreOrphanedWorktree;
|
|
12
14
|
private resolveRepoPath;
|
|
13
15
|
private managedRepoPath;
|
|
14
16
|
private findExistingRepoPath;
|
|
@@ -19,5 +21,10 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
19
21
|
private currentBranch;
|
|
20
22
|
private ensureGitIdentity;
|
|
21
23
|
}
|
|
24
|
+
type WorktreeRename = (source: string, destination: string) => Promise<void>;
|
|
25
|
+
type WorktreeRenameDelay = (milliseconds: number) => Promise<void>;
|
|
26
|
+
export declare function renameWorktreeForRelease(source: string, destination: string, rename?: WorktreeRename, wait?: WorktreeRenameDelay, maxAttempts?: number): Promise<void>;
|
|
27
|
+
export declare function requiresMutableEnvironmentPreparation(request: Pick<WorktreePrepareRequest, "applicationRuntimeRequired">): boolean;
|
|
22
28
|
export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
|
|
23
29
|
export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
|
|
30
|
+
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
|
|
@@ -5,6 +6,7 @@ import { GitClient } from "../shared/git/git.js";
|
|
|
5
6
|
import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
|
|
6
7
|
import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
7
8
|
import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
|
|
9
|
+
import { materializeStoryQaReferences } from "./story-qa-reference-hydrator.js";
|
|
8
10
|
import { warmWorktreeDependencies } from "./dependency-warmer.js";
|
|
9
11
|
import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
|
|
10
12
|
export class GitWorktreeService {
|
|
@@ -36,37 +38,73 @@ export class GitWorktreeService {
|
|
|
36
38
|
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
37
39
|
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
38
40
|
}
|
|
41
|
+
let selectedWorktreePath = worktreePath;
|
|
39
42
|
if (!(await directoryExists(worktreePath))) {
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
// A prior runner can disappear after Git registers its worktree but before
|
|
44
|
+
// normal cleanup completes. Release that missing-directory registration
|
|
45
|
+
// immediately so retrying the same lease remains idempotent.
|
|
46
|
+
await this.git.run(repoPath, ["worktree", "prune", "--expire", "now"]);
|
|
47
|
+
const registeredWorktreePath = await this.registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments);
|
|
48
|
+
if (!registeredWorktreePath) {
|
|
49
|
+
const branchRef = `refs/heads/${branchName}`;
|
|
50
|
+
if (request.preserveExistingState && await this.gitRefExists(repoPath, branchRef)) {
|
|
51
|
+
// The checkout directory can disappear while its branch still
|
|
52
|
+
// contains completed agent work. Reattach that branch verbatim;
|
|
53
|
+
// `-B ... baseStartPoint` would silently reset it and lose progress.
|
|
54
|
+
await this.git.run(repoPath, ["worktree", "add", worktreePath, branchName]);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
|
|
58
|
+
}
|
|
59
|
+
return await resolveExistingDirectoryPath(worktreePath);
|
|
60
|
+
}
|
|
61
|
+
// The selected checkout can change when a Bridge reconnects or a local
|
|
62
|
+
// runner takes over. Its managed root therefore changes even though the
|
|
63
|
+
// deterministic lease branch does not. Reattach to the registered,
|
|
64
|
+
// Forge-owned worktree instead of trying to check out that branch twice.
|
|
65
|
+
selectedWorktreePath = registeredWorktreePath;
|
|
42
66
|
}
|
|
43
|
-
const existingWorktreePath = await resolveExistingDirectoryPath(
|
|
67
|
+
const existingWorktreePath = await resolveExistingDirectoryPath(selectedWorktreePath);
|
|
44
68
|
if (!isSubPath(allowedRoot, existingWorktreePath)) {
|
|
45
69
|
throw new Error(`Worktree path must stay under ${allowedRoot}.`);
|
|
46
70
|
}
|
|
71
|
+
if (!(await gitMetadataExists(existingWorktreePath, allowedRoot))) {
|
|
72
|
+
await this.restoreOrphanedWorktree({
|
|
73
|
+
repoPath,
|
|
74
|
+
worktreePath: existingWorktreePath,
|
|
75
|
+
branchName,
|
|
76
|
+
baseStartPoint,
|
|
77
|
+
allowedRoot,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
47
80
|
const currentBranch = await this.currentBranch(existingWorktreePath);
|
|
48
81
|
if (currentBranch !== branchName) {
|
|
49
82
|
throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
|
|
50
83
|
}
|
|
51
|
-
if (shouldResetExistingWorktree(request)) {
|
|
84
|
+
if (!request.preserveExistingState && shouldResetExistingWorktree(request)) {
|
|
52
85
|
await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
|
|
53
86
|
}
|
|
54
87
|
return existingWorktreePath;
|
|
55
88
|
});
|
|
56
|
-
|
|
57
|
-
await
|
|
89
|
+
// Reference inputs and an exact-tree local repair checkpoint must survive output cleanup.
|
|
90
|
+
await materializeStoryQaReferences(resolvedWorktreePath, request, this.git);
|
|
91
|
+
if (!request.preserveExistingState) {
|
|
92
|
+
await clearRunArtifactRoots(resolvedWorktreePath, request);
|
|
93
|
+
}
|
|
58
94
|
const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
|
|
59
95
|
const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
|
|
60
96
|
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
61
97
|
if (artifacts.length > 0) {
|
|
62
98
|
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
|
|
63
99
|
}
|
|
64
|
-
const dependencyWarm =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
100
|
+
const dependencyWarm = requiresMutableEnvironmentPreparation(request)
|
|
101
|
+
? await warmWorktreeDependencies({
|
|
102
|
+
baseCheckoutPath: repoPath,
|
|
103
|
+
worktreePath: resolvedWorktreePath,
|
|
104
|
+
allowedRoot,
|
|
105
|
+
warmPathOverrides: request.warmPathOverrides
|
|
106
|
+
})
|
|
107
|
+
: undefined;
|
|
70
108
|
return {
|
|
71
109
|
repoPath,
|
|
72
110
|
worktreePath: resolvedWorktreePath,
|
|
@@ -82,6 +120,19 @@ export class GitWorktreeService {
|
|
|
82
120
|
const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
|
|
83
121
|
return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
|
|
84
122
|
}
|
|
123
|
+
async registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments) {
|
|
124
|
+
const listing = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
|
|
125
|
+
const registered = parseRegisteredWorktrees(repoPath, listing)
|
|
126
|
+
.find(entry => entry.branchName === `refs/heads/${branchName}`);
|
|
127
|
+
if (!registered) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const resolved = await resolveExistingDirectoryPath(registered.worktreePath);
|
|
131
|
+
if (!isManagedLeaseWorktreePath(allowedRoot, resolved, worktreeSegments)) {
|
|
132
|
+
throw new Error(`Branch '${branchName}' is already checked out outside its Forge-managed lease path at ${resolved}.`);
|
|
133
|
+
}
|
|
134
|
+
return resolved;
|
|
135
|
+
}
|
|
85
136
|
repoCreationCoordinationPath(repo, allowedRoot) {
|
|
86
137
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
87
138
|
return path.resolve(repo.serverLocalPath);
|
|
@@ -93,18 +144,58 @@ export class GitWorktreeService {
|
|
|
93
144
|
return;
|
|
94
145
|
}
|
|
95
146
|
await this.coordinator.withLock(handle.repoPath, async () => {
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await
|
|
147
|
+
if (await directoryExists(handle.worktreePath)) {
|
|
148
|
+
// On Windows, `git worktree remove` can delete the `.git` link and then
|
|
149
|
+
// fail on a locked build output. That leaves a full source tree which is
|
|
150
|
+
// no longer a Git worktree and makes every retry fail during prepare.
|
|
151
|
+
// Move the tree out of its stable lease path first. If the rename fails,
|
|
152
|
+
// leave the valid worktree untouched rather than partially unregistering it.
|
|
153
|
+
const releasePath = `${handle.worktreePath}.forge-release-${randomUUID()}`;
|
|
154
|
+
await renameWorktreeForRelease(handle.worktreePath, releasePath);
|
|
155
|
+
await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]);
|
|
156
|
+
await fs.rm(releasePath, {
|
|
157
|
+
recursive: true,
|
|
158
|
+
force: true,
|
|
159
|
+
maxRetries: 10,
|
|
160
|
+
retryDelay: 100,
|
|
161
|
+
}).catch(() => undefined);
|
|
105
162
|
}
|
|
163
|
+
// The process can disappear after Git registers a worktree but before the
|
|
164
|
+
// directory is fully materialized (or after an external cleanup removes it).
|
|
165
|
+
// Always prune so those missing-directory registrations do not accumulate.
|
|
166
|
+
await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]).catch(() => undefined);
|
|
106
167
|
});
|
|
107
168
|
}
|
|
169
|
+
async restoreOrphanedWorktree(input) {
|
|
170
|
+
const orphanStat = await fs.lstat(input.worktreePath);
|
|
171
|
+
if (orphanStat.isSymbolicLink() || !orphanStat.isDirectory()) {
|
|
172
|
+
throw new Error(`Refusing to recover redirected or non-directory worktree ${input.worktreePath}.`);
|
|
173
|
+
}
|
|
174
|
+
const quarantinePath = `${input.worktreePath}.forge-orphan-${randomUUID()}`;
|
|
175
|
+
if (!isSubPath(input.allowedRoot, quarantinePath)) {
|
|
176
|
+
throw new Error(`Worktree recovery path must stay under ${input.allowedRoot}.`);
|
|
177
|
+
}
|
|
178
|
+
await fs.rename(input.worktreePath, quarantinePath);
|
|
179
|
+
try {
|
|
180
|
+
await this.git.run(input.repoPath, ["worktree", "prune", "--expire", "now"]);
|
|
181
|
+
await this.git.run(input.repoPath, [
|
|
182
|
+
"worktree",
|
|
183
|
+
"add",
|
|
184
|
+
"-B",
|
|
185
|
+
input.branchName,
|
|
186
|
+
input.worktreePath,
|
|
187
|
+
input.baseStartPoint,
|
|
188
|
+
]);
|
|
189
|
+
await overlayOrphanedWorktree(input.worktreePath, quarantinePath);
|
|
190
|
+
await fs.rm(quarantinePath, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
await this.git.run(input.repoPath, ["worktree", "remove", "--force", input.worktreePath]).catch(() => undefined);
|
|
194
|
+
await fs.rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
|
|
195
|
+
await fs.rename(quarantinePath, input.worktreePath).catch(() => undefined);
|
|
196
|
+
throw new Error(`Failed to recover orphaned worktree ${input.worktreePath}.`, { cause: error });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
108
199
|
async resolveRepoPath(repo, allowedRoot, baseBranch) {
|
|
109
200
|
if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
|
|
110
201
|
const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
|
|
@@ -233,6 +324,32 @@ export class GitWorktreeService {
|
|
|
233
324
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
234
325
|
}
|
|
235
326
|
}
|
|
327
|
+
export async function renameWorktreeForRelease(source, destination, rename = fs.rename, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), maxAttempts = 8) {
|
|
328
|
+
const attempts = Number.isFinite(maxAttempts) && maxAttempts > 0
|
|
329
|
+
? Math.max(1, Math.floor(maxAttempts))
|
|
330
|
+
: 1;
|
|
331
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
332
|
+
try {
|
|
333
|
+
await rename(source, destination);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (!isTransientWorktreeRenameError(error) || attempt === attempts)
|
|
338
|
+
throw error;
|
|
339
|
+
await wait(Math.min(1_000, 100 * (2 ** (attempt - 1))));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function isTransientWorktreeRenameError(error) {
|
|
344
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
345
|
+
return false;
|
|
346
|
+
const code = String(error.code ?? "").toUpperCase();
|
|
347
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "ENOTEMPTY";
|
|
348
|
+
}
|
|
349
|
+
export function requiresMutableEnvironmentPreparation(request) {
|
|
350
|
+
// Missing runtime intent is treated conservatively for older dispatch packets.
|
|
351
|
+
return request.applicationRuntimeRequired !== false;
|
|
352
|
+
}
|
|
236
353
|
function isCloneableRemote(remoteUrl) {
|
|
237
354
|
const trimmed = remoteUrl?.trim() ?? "";
|
|
238
355
|
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
@@ -317,6 +434,121 @@ function shouldResetExistingWorktree(request) {
|
|
|
317
434
|
}
|
|
318
435
|
return true;
|
|
319
436
|
}
|
|
437
|
+
function parseRegisteredWorktrees(repoPath, output) {
|
|
438
|
+
const worktrees = [];
|
|
439
|
+
let current = null;
|
|
440
|
+
for (const field of output.split("\0")) {
|
|
441
|
+
if (field.startsWith("worktree ")) {
|
|
442
|
+
if (current)
|
|
443
|
+
worktrees.push(current);
|
|
444
|
+
const rawPath = field.slice("worktree ".length);
|
|
445
|
+
current = {
|
|
446
|
+
worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
|
|
447
|
+
branchName: null,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
else if (field.startsWith("branch ") && current) {
|
|
451
|
+
current.branchName = field.slice("branch ".length).trim();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (current)
|
|
455
|
+
worktrees.push(current);
|
|
456
|
+
return worktrees;
|
|
457
|
+
}
|
|
458
|
+
function isManagedLeaseWorktreePath(allowedRoot, candidate, worktreeSegments) {
|
|
459
|
+
if (!isSubPath(allowedRoot, candidate)) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
const relativeSegments = path.relative(path.resolve(allowedRoot), path.resolve(candidate))
|
|
463
|
+
.split(path.sep)
|
|
464
|
+
.filter(Boolean);
|
|
465
|
+
const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
|
|
466
|
+
if (normalize(relativeSegments[0] ?? "") !== ".forge-worktrees"
|
|
467
|
+
|| relativeSegments.length !== 2 + worktreeSegments.length) {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
const suffix = relativeSegments.slice(-worktreeSegments.length).map(normalize);
|
|
471
|
+
return suffix.every((segment, index) => segment === normalize(worktreeSegments[index] ?? ""));
|
|
472
|
+
}
|
|
473
|
+
async function overlayOrphanedWorktree(worktreePath, orphanPath) {
|
|
474
|
+
// A failed Windows cleanup can remove only part of a worktree before its
|
|
475
|
+
// `.git` link disappears. Start from Git's clean checkout so missing tracked
|
|
476
|
+
// files are restored, then overlay every surviving agent file. Moving entries
|
|
477
|
+
// on the same volume preserves pnpm junctions without requiring symlink
|
|
478
|
+
// privileges, unlike fs.cp.
|
|
479
|
+
const backupRoot = `${worktreePath}.forge-base-${randomUUID()}`;
|
|
480
|
+
const moves = [];
|
|
481
|
+
try {
|
|
482
|
+
await fs.mkdir(backupRoot, { recursive: true });
|
|
483
|
+
const orphanEntries = await fs.readdir(orphanPath, { withFileTypes: true });
|
|
484
|
+
for (const entry of orphanEntries) {
|
|
485
|
+
if (entry.name === ".git") {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
await overlayEntry({
|
|
489
|
+
sourcePath: path.join(orphanPath, entry.name),
|
|
490
|
+
destinationPath: path.join(worktreePath, entry.name),
|
|
491
|
+
backupPath: path.join(backupRoot, entry.name),
|
|
492
|
+
moves,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
await fs.rm(backupRoot, { recursive: true, force: true });
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
for (const move of moves.reverse()) {
|
|
499
|
+
await fs.mkdir(path.dirname(move.sourcePath), { recursive: true }).catch(() => undefined);
|
|
500
|
+
await fs.rename(move.destinationPath, move.sourcePath).catch(() => undefined);
|
|
501
|
+
if (move.backupPath) {
|
|
502
|
+
await fs.mkdir(path.dirname(move.destinationPath), { recursive: true }).catch(() => undefined);
|
|
503
|
+
await fs.rename(move.backupPath, move.destinationPath).catch(() => undefined);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await fs.rm(backupRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function overlayEntry(input) {
|
|
511
|
+
const sourceStat = await fs.lstat(input.sourcePath);
|
|
512
|
+
const destinationStat = await fs.lstat(input.destinationPath).catch(() => null);
|
|
513
|
+
if (sourceStat.isDirectory() &&
|
|
514
|
+
!sourceStat.isSymbolicLink() &&
|
|
515
|
+
destinationStat?.isDirectory() &&
|
|
516
|
+
!destinationStat.isSymbolicLink()) {
|
|
517
|
+
const entries = await fs.readdir(input.sourcePath, { withFileTypes: true });
|
|
518
|
+
for (const entry of entries) {
|
|
519
|
+
await overlayEntry({
|
|
520
|
+
sourcePath: path.join(input.sourcePath, entry.name),
|
|
521
|
+
destinationPath: path.join(input.destinationPath, entry.name),
|
|
522
|
+
backupPath: path.join(input.backupPath, entry.name),
|
|
523
|
+
moves: input.moves,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
let preservedBasePath;
|
|
529
|
+
if (destinationStat) {
|
|
530
|
+
await fs.mkdir(path.dirname(input.backupPath), { recursive: true });
|
|
531
|
+
await fs.rename(input.destinationPath, input.backupPath);
|
|
532
|
+
preservedBasePath = input.backupPath;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
await fs.mkdir(path.dirname(input.destinationPath), { recursive: true });
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
await fs.rename(input.sourcePath, input.destinationPath);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (preservedBasePath) {
|
|
542
|
+
await fs.rename(preservedBasePath, input.destinationPath).catch(() => undefined);
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
input.moves.push({
|
|
547
|
+
sourcePath: input.sourcePath,
|
|
548
|
+
destinationPath: input.destinationPath,
|
|
549
|
+
backupPath: preservedBasePath,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
320
552
|
function qaReworkPacketFileName(packetPath) {
|
|
321
553
|
const normalized = packetPath.trim().replace(/\\/g, "/");
|
|
322
554
|
const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
|
|
1
|
+
import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact, DispatchStoryQaContext } from "../session/session.types.js";
|
|
2
2
|
import type { WarmDependenciesResult } from "./dependency-warmer.js";
|
|
3
3
|
export interface WorktreePrepareRequest {
|
|
4
4
|
readonly repo: DispatchRepo;
|
|
@@ -13,8 +13,14 @@ export interface WorktreePrepareRequest {
|
|
|
13
13
|
readonly cleanupWorktrees: boolean;
|
|
14
14
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
15
15
|
readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
|
|
16
|
+
readonly storyQa?: DispatchStoryQaContext | null;
|
|
17
|
+
readonly referenceRunId?: string;
|
|
18
|
+
readonly referenceCycleRunId?: string;
|
|
19
|
+
readonly effectivePlanAmendmentHash?: string | null;
|
|
16
20
|
readonly qaRework?: DispatchQaReworkContext | null;
|
|
17
21
|
readonly warmPathOverrides?: readonly string[];
|
|
22
|
+
readonly applicationRuntimeRequired?: boolean;
|
|
23
|
+
readonly preserveExistingState?: boolean;
|
|
18
24
|
}
|
|
19
25
|
export interface WorktreeHandle {
|
|
20
26
|
readonly repoPath: string;
|