@loucompanion/forge-bridge 0.1.3-dev.b3bce6ba65a6 → 0.1.3-dev.ba293ccf876b

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.
Files changed (189) hide show
  1. package/README.md +127 -0
  2. package/dist/bridge/bridge.base.d.ts +8 -1
  3. package/dist/bridge/bridge.service.d.ts +98 -3
  4. package/dist/bridge/bridge.service.js +1425 -45
  5. package/dist/bridge/bridge.types.d.ts +205 -1
  6. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  7. package/dist/bridge/readiness-result-outbox.js +54 -0
  8. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  9. package/dist/bridge/readiness-result-worker.js +100 -0
  10. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  11. package/dist/bridge/runner-host-capabilities.js +93 -0
  12. package/dist/cleanup/cleanup.service.d.ts +14 -2
  13. package/dist/cleanup/cleanup.service.js +496 -3
  14. package/dist/cleanup/cleanup.types.d.ts +10 -0
  15. package/dist/cleanup/index.d.ts +1 -1
  16. package/dist/cleanup/index.js +1 -1
  17. package/dist/cleanup/internals.d.ts +2 -2
  18. package/dist/cleanup/internals.js +1 -1
  19. package/dist/cli/cli.help.js +47 -1
  20. package/dist/cli/cli.service.d.ts +45 -2
  21. package/dist/cli/cli.service.js +377 -27
  22. package/dist/cli/local-config.d.ts +5 -0
  23. package/dist/cli/local-config.js +45 -1
  24. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  25. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  26. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  27. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  28. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  29. package/dist/runtime/attempt-path-operation.js +94 -0
  30. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  31. package/dist/runtime/attempt-private-directory.js +54 -0
  32. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  33. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  34. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  35. package/dist/runtime/attempt-process-supervisor.js +386 -0
  36. package/dist/runtime/canonical-source-file.d.ts +3 -0
  37. package/dist/runtime/canonical-source-file.js +54 -0
  38. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  39. package/dist/runtime/docker-engine-probe.js +300 -0
  40. package/dist/runtime/generated-output-reset.d.ts +10 -0
  41. package/dist/runtime/generated-output-reset.js +103 -0
  42. package/dist/runtime/host-diagnostics.d.ts +4 -0
  43. package/dist/runtime/host-diagnostics.js +95 -0
  44. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  45. package/dist/runtime/host-listener-inspector.js +349 -0
  46. package/dist/runtime/host-port-broker.d.ts +76 -0
  47. package/dist/runtime/host-port-broker.js +333 -0
  48. package/dist/runtime/host-runtime-contract.d.ts +56 -0
  49. package/dist/runtime/host-runtime-contract.js +709 -0
  50. package/dist/runtime/host-runtime-lease.d.ts +201 -0
  51. package/dist/runtime/host-runtime-lease.js +2314 -0
  52. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  53. package/dist/runtime/host-runtime-promotion.js +206 -0
  54. package/dist/runtime/memory-capacity.d.ts +68 -0
  55. package/dist/runtime/memory-capacity.js +121 -0
  56. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  57. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  58. package/dist/runtime/pinned-directory-process.d.ts +4 -0
  59. package/dist/runtime/pinned-directory-process.js +20 -0
  60. package/dist/runtime/pinned-source-directory.d.ts +9 -0
  61. package/dist/runtime/pinned-source-directory.js +56 -0
  62. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  63. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  64. package/dist/runtime/prepared-runtime-identity.d.ts +3 -0
  65. package/dist/runtime/prepared-runtime-identity.js +191 -0
  66. package/dist/runtime/prepared-runtime-readiness.d.ts +22 -0
  67. package/dist/runtime/prepared-runtime-readiness.js +191 -0
  68. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  69. package/dist/runtime/runtime-isolation-lease.js +158 -0
  70. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  71. package/dist/runtime/workspace-resource-estimator.js +174 -0
  72. package/dist/sandbox/command-runner.d.ts +4 -0
  73. package/dist/sandbox/command-runner.js +47 -0
  74. package/dist/sandbox/contract.d.ts +5 -0
  75. package/dist/sandbox/contract.js +276 -0
  76. package/dist/sandbox/event-budget.d.ts +11 -0
  77. package/dist/sandbox/event-budget.js +27 -0
  78. package/dist/sandbox/image-policy.d.ts +35 -0
  79. package/dist/sandbox/image-policy.js +247 -0
  80. package/dist/sandbox/index.d.ts +5 -0
  81. package/dist/sandbox/index.js +5 -0
  82. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  83. package/dist/sandbox/lease-watchdog.js +67 -0
  84. package/dist/sandbox/preflight.d.ts +84 -0
  85. package/dist/sandbox/preflight.js +521 -0
  86. package/dist/sandbox/process.d.ts +20 -0
  87. package/dist/sandbox/process.js +209 -0
  88. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  89. package/dist/sandbox/provider-auth/codex.js +212 -0
  90. package/dist/sandbox/repository-lock.d.ts +5 -0
  91. package/dist/sandbox/repository-lock.js +121 -0
  92. package/dist/sandbox/runtime.d.ts +297 -0
  93. package/dist/sandbox/runtime.js +2338 -0
  94. package/dist/sandbox/sandbox.types.d.ts +93 -0
  95. package/dist/sandbox/sandbox.types.js +1 -0
  96. package/dist/sandbox/smoke.d.ts +76 -0
  97. package/dist/sandbox/smoke.js +380 -0
  98. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  99. package/dist/sandbox/worker-supervisor.js +36 -0
  100. package/dist/sandbox/worker.d.ts +1 -0
  101. package/dist/sandbox/worker.js +225 -0
  102. package/dist/sandbox/workspace.service.d.ts +13 -0
  103. package/dist/sandbox/workspace.service.js +72 -0
  104. package/dist/session/managed-attempt-tools.d.ts +21 -0
  105. package/dist/session/managed-attempt-tools.js +912 -0
  106. package/dist/session/managed-build-coordinator.d.ts +5 -0
  107. package/dist/session/managed-build-coordinator.js +105 -0
  108. package/dist/session/managed-build-receipt.d.ts +21 -0
  109. package/dist/session/managed-build-receipt.js +335 -0
  110. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  111. package/dist/session/phase-completion.artifacts.js +256 -26
  112. package/dist/session/phase-completion.d.ts +12 -2
  113. package/dist/session/phase-completion.helpers.d.ts +5 -0
  114. package/dist/session/phase-completion.helpers.js +51 -10
  115. package/dist/session/phase-completion.js +14 -3
  116. package/dist/session/phase-completion.prompts.d.ts +3 -3
  117. package/dist/session/phase-completion.prompts.js +24 -8
  118. package/dist/session/phase-completion.runtime.d.ts +13 -1
  119. package/dist/session/phase-completion.runtime.js +189 -8
  120. package/dist/session/phase-completion.schemas.js +117 -4
  121. package/dist/session/phase-completion.validation.d.ts +4 -1
  122. package/dist/session/phase-completion.validation.js +153 -22
  123. package/dist/session/pre-publication-validation.d.ts +47 -0
  124. package/dist/session/pre-publication-validation.js +249 -0
  125. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  126. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  127. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  128. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  129. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  130. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +2 -0
  131. package/dist/session/provider-cli/provider-cli.deterministic.js +68 -9
  132. package/dist/session/provider-cli/provider-cli.process.d.ts +26 -0
  133. package/dist/session/provider-cli/provider-cli.process.js +474 -23
  134. package/dist/session/provider-cli/provider-cli.prompts.d.ts +14 -2
  135. package/dist/session/provider-cli/provider-cli.prompts.js +147 -25
  136. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  137. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  138. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  139. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  140. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  141. package/dist/session/provider-cli/provider-cli.service.d.ts +25 -2
  142. package/dist/session/provider-cli/provider-cli.service.js +636 -84
  143. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  144. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  145. package/dist/session/provider-cli/provider-output-state.js +91 -16
  146. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  147. package/dist/session/provider-cli/provider-usage.js +28 -5
  148. package/dist/session/session-redaction.d.ts +3 -0
  149. package/dist/session/session-redaction.js +38 -0
  150. package/dist/session/session.base.d.ts +6 -1
  151. package/dist/session/session.service.d.ts +8 -2
  152. package/dist/session/session.service.js +531 -42
  153. package/dist/session/session.types.d.ts +115 -1
  154. package/dist/session/session.types.js +194 -3
  155. package/dist/shared/format/format-bytes.d.ts +1 -0
  156. package/dist/shared/format/format-bytes.js +9 -0
  157. package/dist/shared/git/git.js +2 -1
  158. package/dist/shared/path/path.d.ts +1 -0
  159. package/dist/shared/path/path.js +21 -1
  160. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  161. package/dist/shared/process/dotnet-toolchain.js +179 -0
  162. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  163. package/dist/shared/process/host-process-recovery.js +78 -0
  164. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  165. package/dist/shared/process/host-process-supervisor.js +330 -0
  166. package/dist/shared/process/process.d.ts +2 -0
  167. package/dist/shared/process/process.js +11 -6
  168. package/dist/shared/provider-capabilities.d.ts +1 -0
  169. package/dist/shared/provider-capabilities.js +3 -0
  170. package/dist/shared/redaction/redaction.js +12 -2
  171. package/dist/version.d.ts +1 -1
  172. package/dist/version.js +1 -1
  173. package/dist/worktree/artifact-injector.d.ts +3 -2
  174. package/dist/worktree/artifact-injector.js +43 -4
  175. package/dist/worktree/dependency-warmer.d.ts +28 -1
  176. package/dist/worktree/dependency-warmer.js +308 -9
  177. package/dist/worktree/index.d.ts +2 -1
  178. package/dist/worktree/index.js +1 -0
  179. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  180. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  181. package/dist/worktree/worktree-paths.d.ts +4 -0
  182. package/dist/worktree/worktree-paths.js +30 -0
  183. package/dist/worktree/worktree.base.d.ts +3 -0
  184. package/dist/worktree/worktree.service.d.ts +14 -2
  185. package/dist/worktree/worktree.service.js +296 -47
  186. package/dist/worktree/worktree.types.d.ts +5 -1
  187. package/package.json +8 -4
  188. package/runtime/windows-job-supervisor.ps1 +444 -0
  189. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,136 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isSubPath } from "../shared/path/path.js";
5
+ const MAX_ARTIFACT_COUNT = 128;
6
+ const MAX_CONTENT_BYTES = 2 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 12 * 1024 * 1024;
8
+ const phaseFiles = {
9
+ implementation: {
10
+ verdict: "implementation.completeness.json",
11
+ report: "implementation.report.md"
12
+ },
13
+ self_review: {
14
+ verdict: "self-review.report.json",
15
+ report: "self-review.summary.md"
16
+ },
17
+ validation: {
18
+ verdict: "validation.verdict.json",
19
+ report: "validation.report.md"
20
+ }
21
+ };
22
+ export async function hydratePriorPhaseArtifacts(worktreePath, artifacts = []) {
23
+ if (artifacts.length === 0)
24
+ return 0;
25
+ if (artifacts.length > MAX_ARTIFACT_COUNT) {
26
+ throw new Error(`Prior phase artifact handoff exceeds ${MAX_ARTIFACT_COUNT} artifacts.`);
27
+ }
28
+ const targets = new Set();
29
+ let totalBytes = 0;
30
+ for (const artifact of artifacts) {
31
+ const workItemId = safeArtifactSegment(artifact.workItemId, "work item id");
32
+ const files = phaseFiles[artifact.phase];
33
+ if (!files) {
34
+ throw new Error(`Unsupported prior phase artifact phase '${artifact.phase}'.`);
35
+ }
36
+ const verdictBytes = Buffer.byteLength(artifact.verdictJson, "utf8");
37
+ const reportBytes = Buffer.byteLength(artifact.report, "utf8");
38
+ if (verdictBytes > MAX_CONTENT_BYTES || reportBytes > MAX_CONTENT_BYTES) {
39
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' exceeds the per-file hydration limit.`);
40
+ }
41
+ totalBytes += verdictBytes + reportBytes;
42
+ if (totalBytes > MAX_TOTAL_BYTES) {
43
+ throw new Error("Prior phase artifact handoff exceeds the total hydration limit.");
44
+ }
45
+ verifyContentHash(artifact.verdictJson, artifact.verdictContentHash, artifact.artifactId, "verdict");
46
+ verifyContentHash(artifact.report, artifact.reportContentHash, artifact.artifactId, "report");
47
+ validateVerdict(artifact);
48
+ const artifactRoot = path.resolve(worktreePath, ".forge", "work-items", workItemId, "artifacts");
49
+ if (!isSubPath(worktreePath, artifactRoot)) {
50
+ throw new Error("Prior phase artifact target must stay under the worktree.");
51
+ }
52
+ await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
53
+ await fs.mkdir(artifactRoot, { recursive: true });
54
+ await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
55
+ const verdictPath = path.join(artifactRoot, files.verdict);
56
+ const reportPath = path.join(artifactRoot, files.report);
57
+ const targetKey = verdictPath.toLowerCase();
58
+ if (targets.has(targetKey)) {
59
+ throw new Error(`Duplicate prior phase artifact target for work item '${workItemId}' phase '${artifact.phase}'.`);
60
+ }
61
+ targets.add(targetKey);
62
+ await writeAtomic(verdictPath, artifact.verdictJson);
63
+ await writeAtomic(reportPath, artifact.report);
64
+ }
65
+ return artifacts.length;
66
+ }
67
+ function validateVerdict(artifact) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(artifact.verdictJson);
71
+ }
72
+ catch {
73
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict is not valid JSON.`);
74
+ }
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
76
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict must be a JSON object.`);
77
+ }
78
+ const recordedPhase = parsed["phase"];
79
+ const normalizedPhase = typeof recordedPhase === "string"
80
+ ? recordedPhase.trim().toLowerCase().replace(/-/g, "_")
81
+ : "";
82
+ if (normalizedPhase !== artifact.phase) {
83
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' phase does not match its verdict.`);
84
+ }
85
+ }
86
+ function verifyContentHash(content, expected, artifactId, label) {
87
+ const normalizedExpected = expected.trim().toLowerCase().replace(/^sha256:/, "");
88
+ if (!/^[0-9a-f]{64}$/.test(normalizedExpected)) {
89
+ throw new Error(`Prior phase artifact '${artifactId}' ${label} hash is invalid.`);
90
+ }
91
+ const actual = createHash("sha256").update(content, "utf8").digest("hex");
92
+ if (actual !== normalizedExpected) {
93
+ throw new Error(`Prior phase artifact '${artifactId}' ${label} content_hash mismatch.`);
94
+ }
95
+ }
96
+ function safeArtifactSegment(value, label) {
97
+ const trimmed = value.trim();
98
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(trimmed)) {
99
+ throw new Error(`Prior phase artifact ${label} is not a safe path segment.`);
100
+ }
101
+ return trimmed;
102
+ }
103
+ async function writeAtomic(targetPath, content) {
104
+ const temporaryPath = `${targetPath}.tmp-${randomUUID()}`;
105
+ try {
106
+ await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
107
+ await fs.rename(temporaryPath, targetPath);
108
+ }
109
+ finally {
110
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
111
+ }
112
+ }
113
+ async function ensureNoSymlinkPathSegments(root, targetPath) {
114
+ const resolvedRoot = path.resolve(root);
115
+ const resolvedTarget = path.resolve(targetPath);
116
+ const relative = path.relative(resolvedRoot, resolvedTarget);
117
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
118
+ throw new Error("Prior phase artifact path must stay under the worktree.");
119
+ }
120
+ let current = resolvedRoot;
121
+ for (const segment of relative.split(path.sep)) {
122
+ if (!segment)
123
+ continue;
124
+ current = path.join(current, segment);
125
+ const stat = await fs.lstat(current).catch((error) => {
126
+ if (error.code === "ENOENT")
127
+ return null;
128
+ throw error;
129
+ });
130
+ if (!stat)
131
+ return;
132
+ if (stat.isSymbolicLink()) {
133
+ throw new Error("Prior phase artifact path must not traverse symlinked path segments.");
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,4 @@
1
+ export declare function managedRepoRoot(allowedRoot: string): string;
2
+ export declare function resolveManagedWorktreeRoot(allowedRoot: string, repoPath: string): string;
3
+ export declare function resolveManagedWorktreeRootForRepo(repoPath: string, allowedRoot?: string): string;
4
+ export declare function defaultWorktreeCoordinatorStateRoot(): string;
@@ -0,0 +1,30 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { isSubPath } from "../shared/path/path.js";
4
+ export function managedRepoRoot(allowedRoot) {
5
+ return path.join(path.resolve(allowedRoot), ".forge-worktrees", "_repos");
6
+ }
7
+ export function resolveManagedWorktreeRoot(allowedRoot, repoPath) {
8
+ const resolvedAllowedRoot = path.resolve(allowedRoot);
9
+ const resolvedRepoPath = path.resolve(repoPath);
10
+ const baseName = path.basename(resolvedRepoPath);
11
+ return isSubPath(managedRepoRoot(resolvedAllowedRoot), resolvedRepoPath)
12
+ ? path.join(resolvedAllowedRoot, ".forge-worktrees", baseName)
13
+ : path.join(path.dirname(resolvedRepoPath), ".forge-worktrees", baseName);
14
+ }
15
+ export function resolveManagedWorktreeRootForRepo(repoPath, allowedRoot) {
16
+ const resolvedRepoPath = path.resolve(repoPath);
17
+ if (allowedRoot && isSubPath(path.resolve(allowedRoot), resolvedRepoPath)) {
18
+ return resolveManagedWorktreeRoot(allowedRoot, resolvedRepoPath);
19
+ }
20
+ const repoParent = path.dirname(resolvedRepoPath);
21
+ const managedRoot = path.dirname(repoParent);
22
+ if (path.basename(repoParent) === "_repos" && path.basename(managedRoot) === ".forge-worktrees") {
23
+ return path.join(managedRoot, path.basename(resolvedRepoPath));
24
+ }
25
+ return path.join(repoParent, ".forge-worktrees", path.basename(resolvedRepoPath));
26
+ }
27
+ export function defaultWorktreeCoordinatorStateRoot() {
28
+ return process.env.FORGE_SANDBOX_STATE_ROOT
29
+ ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
30
+ }
@@ -3,3 +3,6 @@ export interface WorktreeService {
3
3
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
4
4
  release(handle: WorktreeHandle): Promise<void>;
5
5
  }
6
+ export interface WorktreeLifecycleCoordinator {
7
+ withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
8
+ }
@@ -1,12 +1,18 @@
1
1
  import { GitClient } from "../shared/git/git.js";
2
- import type { WorktreeService } from "./worktree.base.js";
2
+ import type { WorktreeLifecycleCoordinator, WorktreeService } from "./worktree.base.js";
3
3
  import type { WorktreeHandle, WorktreePrepareRequest } from "./worktree.types.js";
4
4
  export declare class GitWorktreeService implements WorktreeService {
5
5
  private readonly git;
6
- constructor(git?: GitClient);
6
+ private readonly coordinator;
7
+ constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
7
8
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
9
+ private trackedForgePaths;
10
+ private registeredManagedWorktreePath;
11
+ private repoCreationCoordinationPath;
8
12
  release(handle: WorktreeHandle): Promise<void>;
13
+ private restoreOrphanedWorktree;
9
14
  private resolveRepoPath;
15
+ private managedRepoPath;
10
16
  private findExistingRepoPath;
11
17
  private resolveMatchingRepo;
12
18
  private resolveBaseStartPoint;
@@ -15,4 +21,10 @@ export declare class GitWorktreeService implements WorktreeService {
15
21
  private currentBranch;
16
22
  private ensureGitIdentity;
17
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;
28
+ export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
18
29
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
30
+ export {};
@@ -1,64 +1,99 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
3
5
  import { GitClient } from "../shared/git/git.js";
4
6
  import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
5
7
  import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
8
+ import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
6
9
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
10
+ import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
7
11
  export class GitWorktreeService {
8
12
  git;
9
- constructor(git = new GitClient()) {
13
+ coordinator;
14
+ constructor(git = new GitClient(), coordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot())) {
10
15
  this.git = git;
16
+ this.coordinator = coordinator;
11
17
  }
12
18
  async prepare(request) {
13
19
  const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
14
20
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
- const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
16
- await this.ensureGitIdentity(repoPath);
17
- const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
18
21
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
19
22
  const kind = safeSegment(request.workItem.kind, "work-item");
20
23
  const fallbackWorktreeKey = `${kind}/${shortId}`;
21
24
  const worktreeKey = request.worktreeKey?.trim() || fallbackWorktreeKey;
22
25
  const worktreeSegments = worktreeKeySegments(worktreeKey, kind, shortId);
23
26
  const branchName = normalizeBranchName(worktreeSegments.join("/"), "work-item/unknown");
24
- const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
25
- const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
26
- const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
27
- if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
28
- throw new Error(`Worktree path must stay under ${allowedRoot}.`);
29
- }
30
- const exists = await directoryExists(worktreePath);
31
- let resolvedWorktreePath;
32
- if (!exists) {
33
- await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
34
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
35
- }
36
- else {
37
- resolvedWorktreePath = await resolveExistingDirectoryPath(worktreePath);
38
- if (!isSubPath(allowedRoot, resolvedWorktreePath)) {
27
+ const creationCoordinationPath = this.repoCreationCoordinationPath(request.repo, allowedRoot);
28
+ const repoPath = await this.coordinator.withLock(creationCoordinationPath, async () => {
29
+ return await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
30
+ });
31
+ const resolvedWorktreePath = await this.coordinator.withLock(repoPath, async () => {
32
+ await this.ensureGitIdentity(repoPath);
33
+ const baseStartPoint = await this.resolveBaseStartPoint(repoPath, baseBranch);
34
+ const worktreeRoot = resolveManagedWorktreeRoot(allowedRoot, repoPath);
35
+ const worktreePath = path.join(worktreeRoot, ...worktreeSegments);
36
+ const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
37
+ if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
38
+ throw new Error(`Worktree path must stay under ${allowedRoot}.`);
39
+ }
40
+ let selectedWorktreePath = worktreePath;
41
+ if (!(await directoryExists(worktreePath))) {
42
+ // A prior runner can disappear after Git registers its worktree but before
43
+ // normal cleanup completes. Release that missing-directory registration
44
+ // immediately so retrying the same lease remains idempotent.
45
+ await this.git.run(repoPath, ["worktree", "prune", "--expire", "now"]);
46
+ const registeredWorktreePath = await this.registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments);
47
+ if (!registeredWorktreePath) {
48
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
49
+ return await resolveExistingDirectoryPath(worktreePath);
50
+ }
51
+ // The selected checkout can change when a Bridge reconnects or a local
52
+ // runner takes over. Its managed root therefore changes even though the
53
+ // deterministic lease branch does not. Reattach to the registered,
54
+ // Forge-owned worktree instead of trying to check out that branch twice.
55
+ selectedWorktreePath = registeredWorktreePath;
56
+ }
57
+ const existingWorktreePath = await resolveExistingDirectoryPath(selectedWorktreePath);
58
+ if (!isSubPath(allowedRoot, existingWorktreePath)) {
39
59
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
40
60
  }
41
- const currentBranch = await this.currentBranch(resolvedWorktreePath);
61
+ if (!(await gitMetadataExists(existingWorktreePath, allowedRoot))) {
62
+ await this.restoreOrphanedWorktree({
63
+ repoPath,
64
+ worktreePath: existingWorktreePath,
65
+ branchName,
66
+ baseStartPoint,
67
+ allowedRoot,
68
+ });
69
+ }
70
+ const currentBranch = await this.currentBranch(existingWorktreePath);
42
71
  if (currentBranch !== branchName) {
43
- throw new Error(`Existing worktree ${resolvedWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
72
+ throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
44
73
  }
45
- if (shouldResetExistingWorktree(request)) {
46
- await this.resetWorktreeToBase(resolvedWorktreePath, baseStartPoint);
74
+ if (!request.preserveExistingState && shouldResetExistingWorktree(request)) {
75
+ await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
47
76
  }
48
- }
77
+ return existingWorktreePath;
78
+ });
49
79
  await this.ensureGitIdentity(resolvedWorktreePath);
50
- await clearRunArtifactRoots(resolvedWorktreePath, request);
80
+ if (!request.preserveExistingState) {
81
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
82
+ }
83
+ const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
51
84
  const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
52
85
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
53
86
  if (artifacts.length > 0) {
54
- await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
87
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
55
88
  }
56
- const dependencyWarm = await warmWorktreeDependencies({
57
- baseCheckoutPath: repoPath,
58
- worktreePath: resolvedWorktreePath,
59
- allowedRoot,
60
- warmPathOverrides: request.warmPathOverrides
61
- });
89
+ const dependencyWarm = requiresMutableEnvironmentPreparation(request)
90
+ ? await warmWorktreeDependencies({
91
+ baseCheckoutPath: repoPath,
92
+ worktreePath: resolvedWorktreePath,
93
+ allowedRoot,
94
+ warmPathOverrides: request.warmPathOverrides
95
+ })
96
+ : undefined;
62
97
  return {
63
98
  repoPath,
64
99
  worktreePath: resolvedWorktreePath,
@@ -66,19 +101,88 @@ export class GitWorktreeService {
66
101
  baseBranch,
67
102
  cleanupWorktrees: request.cleanupWorktrees,
68
103
  dependencyWarm,
69
- qaReworkPacketPath
104
+ qaReworkPacketPath,
105
+ hydratedPriorPhaseArtifactCount
70
106
  };
71
107
  }
108
+ async trackedForgePaths(worktreePath) {
109
+ const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
110
+ return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
111
+ }
112
+ async registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments) {
113
+ const listing = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
114
+ const registered = parseRegisteredWorktrees(repoPath, listing)
115
+ .find(entry => entry.branchName === `refs/heads/${branchName}`);
116
+ if (!registered) {
117
+ return null;
118
+ }
119
+ const resolved = await resolveExistingDirectoryPath(registered.worktreePath);
120
+ if (!isManagedLeaseWorktreePath(allowedRoot, resolved, worktreeSegments)) {
121
+ throw new Error(`Branch '${branchName}' is already checked out outside its Forge-managed lease path at ${resolved}.`);
122
+ }
123
+ return resolved;
124
+ }
125
+ repoCreationCoordinationPath(repo, allowedRoot) {
126
+ if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
127
+ return path.resolve(repo.serverLocalPath);
128
+ }
129
+ return this.managedRepoPath(repo, allowedRoot);
130
+ }
72
131
  async release(handle) {
73
- if (!handle.cleanupWorktrees || !(await directoryExists(handle.worktreePath))) {
132
+ if (!handle.cleanupWorktrees) {
74
133
  return;
75
134
  }
135
+ await this.coordinator.withLock(handle.repoPath, async () => {
136
+ if (await directoryExists(handle.worktreePath)) {
137
+ // On Windows, `git worktree remove` can delete the `.git` link and then
138
+ // fail on a locked build output. That leaves a full source tree which is
139
+ // no longer a Git worktree and makes every retry fail during prepare.
140
+ // Move the tree out of its stable lease path first. If the rename fails,
141
+ // leave the valid worktree untouched rather than partially unregistering it.
142
+ const releasePath = `${handle.worktreePath}.forge-release-${randomUUID()}`;
143
+ await renameWorktreeForRelease(handle.worktreePath, releasePath);
144
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]);
145
+ await fs.rm(releasePath, {
146
+ recursive: true,
147
+ force: true,
148
+ maxRetries: 10,
149
+ retryDelay: 100,
150
+ }).catch(() => undefined);
151
+ }
152
+ // The process can disappear after Git registers a worktree but before the
153
+ // directory is fully materialized (or after an external cleanup removes it).
154
+ // Always prune so those missing-directory registrations do not accumulate.
155
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]).catch(() => undefined);
156
+ });
157
+ }
158
+ async restoreOrphanedWorktree(input) {
159
+ const orphanStat = await fs.lstat(input.worktreePath);
160
+ if (orphanStat.isSymbolicLink() || !orphanStat.isDirectory()) {
161
+ throw new Error(`Refusing to recover redirected or non-directory worktree ${input.worktreePath}.`);
162
+ }
163
+ const quarantinePath = `${input.worktreePath}.forge-orphan-${randomUUID()}`;
164
+ if (!isSubPath(input.allowedRoot, quarantinePath)) {
165
+ throw new Error(`Worktree recovery path must stay under ${input.allowedRoot}.`);
166
+ }
167
+ await fs.rename(input.worktreePath, quarantinePath);
76
168
  try {
77
- await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
78
- await this.git.run(handle.repoPath, ["worktree", "prune"]);
169
+ await this.git.run(input.repoPath, ["worktree", "prune", "--expire", "now"]);
170
+ await this.git.run(input.repoPath, [
171
+ "worktree",
172
+ "add",
173
+ "-B",
174
+ input.branchName,
175
+ input.worktreePath,
176
+ input.baseStartPoint,
177
+ ]);
178
+ await overlayOrphanedWorktree(input.worktreePath, quarantinePath);
179
+ await fs.rm(quarantinePath, { recursive: true, force: true });
79
180
  }
80
- catch {
81
- await fs.rm(handle.worktreePath, { recursive: true, force: true });
181
+ catch (error) {
182
+ await this.git.run(input.repoPath, ["worktree", "remove", "--force", input.worktreePath]).catch(() => undefined);
183
+ await fs.rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
184
+ await fs.rename(quarantinePath, input.worktreePath).catch(() => undefined);
185
+ throw new Error(`Failed to recover orphaned worktree ${input.worktreePath}.`, { cause: error });
82
186
  }
83
187
  }
84
188
  async resolveRepoPath(repo, allowedRoot, baseBranch) {
@@ -96,7 +200,7 @@ export class GitWorktreeService {
96
200
  if (discovered) {
97
201
  return discovered;
98
202
  }
99
- const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
203
+ const repoPath = this.managedRepoPath(repo, allowedRoot);
100
204
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
101
205
  const repoPathExists = await pathExists(repoPath);
102
206
  if (repoPathExists) {
@@ -127,6 +231,9 @@ export class GitWorktreeService {
127
231
  }
128
232
  return resolved;
129
233
  }
234
+ managedRepoPath(repo, allowedRoot) {
235
+ return path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
236
+ }
130
237
  async findExistingRepoPath(repo, allowedRoot) {
131
238
  const expectedRemote = normalizeRemote(repo.remoteUrl);
132
239
  const rawName = repo.name.trim();
@@ -168,6 +275,13 @@ export class GitWorktreeService {
168
275
  return isSubPath(allowedRoot, resolved) ? resolved : null;
169
276
  }
170
277
  async resolveBaseStartPoint(repoPath, baseBranch) {
278
+ if (baseBranch === "HEAD") {
279
+ const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
280
+ if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
281
+ throw new Error("The repository checkout does not have a valid committed HEAD revision.");
282
+ }
283
+ return head;
284
+ }
171
285
  const localRef = `refs/heads/${baseBranch}`;
172
286
  const remoteRef = `refs/remotes/origin/${baseBranch}`;
173
287
  const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
@@ -199,14 +313,31 @@ export class GitWorktreeService {
199
313
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
200
314
  }
201
315
  }
202
- function managedRepoRoot(allowedRoot) {
203
- return path.join(allowedRoot, ".forge-worktrees", "_repos");
316
+ export async function renameWorktreeForRelease(source, destination, rename = fs.rename, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), maxAttempts = 8) {
317
+ const attempts = Number.isFinite(maxAttempts) && maxAttempts > 0
318
+ ? Math.max(1, Math.floor(maxAttempts))
319
+ : 1;
320
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
321
+ try {
322
+ await rename(source, destination);
323
+ return;
324
+ }
325
+ catch (error) {
326
+ if (!isTransientWorktreeRenameError(error) || attempt === attempts)
327
+ throw error;
328
+ await wait(Math.min(1_000, 100 * (2 ** (attempt - 1))));
329
+ }
330
+ }
331
+ }
332
+ function isTransientWorktreeRenameError(error) {
333
+ if (!error || typeof error !== "object" || !("code" in error))
334
+ return false;
335
+ const code = String(error.code ?? "").toUpperCase();
336
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "ENOTEMPTY";
204
337
  }
205
- function resolveWorktreeRoot(allowedRoot, repoPath) {
206
- const baseName = path.basename(repoPath);
207
- return isSubPath(managedRepoRoot(allowedRoot), repoPath)
208
- ? path.join(allowedRoot, ".forge-worktrees", baseName)
209
- : path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
338
+ export function requiresMutableEnvironmentPreparation(request) {
339
+ // Missing runtime intent is treated conservatively for older dispatch packets.
340
+ return request.applicationRuntimeRequired !== false;
210
341
  }
211
342
  function isCloneableRemote(remoteUrl) {
212
343
  const trimmed = remoteUrl?.trim() ?? "";
@@ -215,9 +346,12 @@ function isCloneableRemote(remoteUrl) {
215
346
  }
216
347
  return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
217
348
  }
218
- async function clearRunArtifactRoots(worktreePath, request) {
349
+ export async function clearRunArtifactRoots(worktreePath, request) {
219
350
  const roots = new Set();
220
351
  roots.add(workItemArtifactRoot(worktreePath, request.workItem.id));
352
+ for (const artifact of request.priorPhaseArtifacts ?? []) {
353
+ roots.add(workItemArtifactRoot(worktreePath, artifact.workItemId));
354
+ }
221
355
  const ownerType = normalizeOwnerType(request.ownerType);
222
356
  const ownerId = request.ownerId?.trim();
223
357
  if (ownerType && ownerId) {
@@ -289,6 +423,121 @@ function shouldResetExistingWorktree(request) {
289
423
  }
290
424
  return true;
291
425
  }
426
+ function parseRegisteredWorktrees(repoPath, output) {
427
+ const worktrees = [];
428
+ let current = null;
429
+ for (const field of output.split("\0")) {
430
+ if (field.startsWith("worktree ")) {
431
+ if (current)
432
+ worktrees.push(current);
433
+ const rawPath = field.slice("worktree ".length);
434
+ current = {
435
+ worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
436
+ branchName: null,
437
+ };
438
+ }
439
+ else if (field.startsWith("branch ") && current) {
440
+ current.branchName = field.slice("branch ".length).trim();
441
+ }
442
+ }
443
+ if (current)
444
+ worktrees.push(current);
445
+ return worktrees;
446
+ }
447
+ function isManagedLeaseWorktreePath(allowedRoot, candidate, worktreeSegments) {
448
+ if (!isSubPath(allowedRoot, candidate)) {
449
+ return false;
450
+ }
451
+ const relativeSegments = path.relative(path.resolve(allowedRoot), path.resolve(candidate))
452
+ .split(path.sep)
453
+ .filter(Boolean);
454
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
455
+ if (normalize(relativeSegments[0] ?? "") !== ".forge-worktrees"
456
+ || relativeSegments.length !== 2 + worktreeSegments.length) {
457
+ return false;
458
+ }
459
+ const suffix = relativeSegments.slice(-worktreeSegments.length).map(normalize);
460
+ return suffix.every((segment, index) => segment === normalize(worktreeSegments[index] ?? ""));
461
+ }
462
+ async function overlayOrphanedWorktree(worktreePath, orphanPath) {
463
+ // A failed Windows cleanup can remove only part of a worktree before its
464
+ // `.git` link disappears. Start from Git's clean checkout so missing tracked
465
+ // files are restored, then overlay every surviving agent file. Moving entries
466
+ // on the same volume preserves pnpm junctions without requiring symlink
467
+ // privileges, unlike fs.cp.
468
+ const backupRoot = `${worktreePath}.forge-base-${randomUUID()}`;
469
+ const moves = [];
470
+ try {
471
+ await fs.mkdir(backupRoot, { recursive: true });
472
+ const orphanEntries = await fs.readdir(orphanPath, { withFileTypes: true });
473
+ for (const entry of orphanEntries) {
474
+ if (entry.name === ".git") {
475
+ continue;
476
+ }
477
+ await overlayEntry({
478
+ sourcePath: path.join(orphanPath, entry.name),
479
+ destinationPath: path.join(worktreePath, entry.name),
480
+ backupPath: path.join(backupRoot, entry.name),
481
+ moves,
482
+ });
483
+ }
484
+ await fs.rm(backupRoot, { recursive: true, force: true });
485
+ }
486
+ catch (error) {
487
+ for (const move of moves.reverse()) {
488
+ await fs.mkdir(path.dirname(move.sourcePath), { recursive: true }).catch(() => undefined);
489
+ await fs.rename(move.destinationPath, move.sourcePath).catch(() => undefined);
490
+ if (move.backupPath) {
491
+ await fs.mkdir(path.dirname(move.destinationPath), { recursive: true }).catch(() => undefined);
492
+ await fs.rename(move.backupPath, move.destinationPath).catch(() => undefined);
493
+ }
494
+ }
495
+ await fs.rm(backupRoot, { recursive: true, force: true }).catch(() => undefined);
496
+ throw error;
497
+ }
498
+ }
499
+ async function overlayEntry(input) {
500
+ const sourceStat = await fs.lstat(input.sourcePath);
501
+ const destinationStat = await fs.lstat(input.destinationPath).catch(() => null);
502
+ if (sourceStat.isDirectory() &&
503
+ !sourceStat.isSymbolicLink() &&
504
+ destinationStat?.isDirectory() &&
505
+ !destinationStat.isSymbolicLink()) {
506
+ const entries = await fs.readdir(input.sourcePath, { withFileTypes: true });
507
+ for (const entry of entries) {
508
+ await overlayEntry({
509
+ sourcePath: path.join(input.sourcePath, entry.name),
510
+ destinationPath: path.join(input.destinationPath, entry.name),
511
+ backupPath: path.join(input.backupPath, entry.name),
512
+ moves: input.moves,
513
+ });
514
+ }
515
+ return;
516
+ }
517
+ let preservedBasePath;
518
+ if (destinationStat) {
519
+ await fs.mkdir(path.dirname(input.backupPath), { recursive: true });
520
+ await fs.rename(input.destinationPath, input.backupPath);
521
+ preservedBasePath = input.backupPath;
522
+ }
523
+ else {
524
+ await fs.mkdir(path.dirname(input.destinationPath), { recursive: true });
525
+ }
526
+ try {
527
+ await fs.rename(input.sourcePath, input.destinationPath);
528
+ }
529
+ catch (error) {
530
+ if (preservedBasePath) {
531
+ await fs.rename(preservedBasePath, input.destinationPath).catch(() => undefined);
532
+ }
533
+ throw error;
534
+ }
535
+ input.moves.push({
536
+ sourcePath: input.sourcePath,
537
+ destinationPath: input.destinationPath,
538
+ backupPath: preservedBasePath,
539
+ });
540
+ }
292
541
  function qaReworkPacketFileName(packetPath) {
293
542
  const normalized = packetPath.trim().replace(/\\/g, "/");
294
543
  const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";