@loucompanion/forge-bridge 0.1.3-dev.9d8f2e7e5241 → 0.1.3-dev.a58d7cd1092f

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 (143) hide show
  1. package/README.md +83 -0
  2. package/dist/bridge/bridge.base.d.ts +4 -1
  3. package/dist/bridge/bridge.service.d.ts +55 -3
  4. package/dist/bridge/bridge.service.js +834 -87
  5. package/dist/bridge/bridge.types.d.ts +89 -1
  6. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  7. package/dist/bridge/runner-host-capabilities.js +93 -0
  8. package/dist/cleanup/cleanup.service.d.ts +14 -2
  9. package/dist/cleanup/cleanup.service.js +496 -3
  10. package/dist/cleanup/cleanup.types.d.ts +10 -0
  11. package/dist/cleanup/index.d.ts +1 -1
  12. package/dist/cleanup/index.js +1 -1
  13. package/dist/cleanup/internals.d.ts +2 -2
  14. package/dist/cleanup/internals.js +1 -1
  15. package/dist/cli/cli.help.js +37 -3
  16. package/dist/cli/cli.service.d.ts +33 -2
  17. package/dist/cli/cli.service.js +209 -27
  18. package/dist/cli/local-config.d.ts +5 -0
  19. package/dist/cli/local-config.js +45 -1
  20. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  21. package/dist/runtime/aspnet-runtime-prebuild.js +340 -0
  22. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  23. package/dist/runtime/attempt-node-runtime-launcher.js +105 -0
  24. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  25. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  26. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  27. package/dist/runtime/attempt-process-supervisor.js +386 -0
  28. package/dist/runtime/docker-engine-probe.d.ts +83 -0
  29. package/dist/runtime/docker-engine-probe.js +300 -0
  30. package/dist/runtime/generated-output-reset.d.ts +10 -0
  31. package/dist/runtime/generated-output-reset.js +103 -0
  32. package/dist/runtime/host-listener-inspector.d.ts +46 -0
  33. package/dist/runtime/host-listener-inspector.js +349 -0
  34. package/dist/runtime/host-port-broker.d.ts +76 -0
  35. package/dist/runtime/host-port-broker.js +333 -0
  36. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  37. package/dist/runtime/host-runtime-contract.js +700 -0
  38. package/dist/runtime/host-runtime-lease.d.ts +197 -0
  39. package/dist/runtime/host-runtime-lease.js +2203 -0
  40. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  41. package/dist/runtime/host-runtime-promotion.js +159 -0
  42. package/dist/runtime/memory-capacity.d.ts +68 -0
  43. package/dist/runtime/memory-capacity.js +121 -0
  44. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  45. package/dist/runtime/node-host-runtime-adapter.js +284 -0
  46. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  47. package/dist/runtime/playwright-host-runtime-adapter.js +231 -0
  48. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  49. package/dist/runtime/runtime-isolation-lease.js +158 -0
  50. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  51. package/dist/runtime/workspace-resource-estimator.js +174 -0
  52. package/dist/sandbox/preflight.d.ts +45 -1
  53. package/dist/sandbox/preflight.js +299 -34
  54. package/dist/sandbox/process.js +21 -2
  55. package/dist/sandbox/repository-lock.js +41 -3
  56. package/dist/sandbox/runtime.d.ts +66 -3
  57. package/dist/sandbox/runtime.js +670 -105
  58. package/dist/sandbox/sandbox.types.d.ts +10 -0
  59. package/dist/sandbox/smoke.d.ts +76 -0
  60. package/dist/sandbox/smoke.js +380 -0
  61. package/dist/sandbox/worker-supervisor.js +1 -1
  62. package/dist/sandbox/worker.js +2 -5
  63. package/dist/sandbox/workspace.service.d.ts +1 -0
  64. package/dist/sandbox/workspace.service.js +28 -5
  65. package/dist/session/managed-attempt-tools.d.ts +21 -0
  66. package/dist/session/managed-attempt-tools.js +912 -0
  67. package/dist/session/managed-build-coordinator.d.ts +5 -0
  68. package/dist/session/managed-build-coordinator.js +105 -0
  69. package/dist/session/managed-build-receipt.d.ts +21 -0
  70. package/dist/session/managed-build-receipt.js +335 -0
  71. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  72. package/dist/session/phase-completion.artifacts.js +233 -24
  73. package/dist/session/phase-completion.d.ts +12 -2
  74. package/dist/session/phase-completion.helpers.d.ts +5 -0
  75. package/dist/session/phase-completion.helpers.js +51 -10
  76. package/dist/session/phase-completion.js +14 -3
  77. package/dist/session/phase-completion.prompts.d.ts +3 -3
  78. package/dist/session/phase-completion.prompts.js +24 -8
  79. package/dist/session/phase-completion.runtime.d.ts +13 -1
  80. package/dist/session/phase-completion.runtime.js +189 -8
  81. package/dist/session/phase-completion.schemas.js +117 -4
  82. package/dist/session/phase-completion.validation.d.ts +4 -1
  83. package/dist/session/phase-completion.validation.js +153 -22
  84. package/dist/session/pre-publication-validation.d.ts +46 -0
  85. package/dist/session/pre-publication-validation.js +208 -0
  86. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  87. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  88. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +3 -2
  89. package/dist/session/provider-cli/provider-cli.artifacts.js +65 -7
  90. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  91. package/dist/session/provider-cli/provider-cli.process.d.ts +25 -0
  92. package/dist/session/provider-cli/provider-cli.process.js +463 -34
  93. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  94. package/dist/session/provider-cli/provider-cli.prompts.js +108 -25
  95. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  96. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  97. package/dist/session/provider-cli/provider-cli.runtime.js +6 -3
  98. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -2
  99. package/dist/session/provider-cli/provider-cli.service.js +529 -100
  100. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  101. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  102. package/dist/session/provider-cli/provider-output-state.js +91 -16
  103. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  104. package/dist/session/provider-cli/provider-usage.js +28 -5
  105. package/dist/session/session-redaction.d.ts +3 -0
  106. package/dist/session/session-redaction.js +38 -0
  107. package/dist/session/session.base.d.ts +6 -1
  108. package/dist/session/session.service.d.ts +8 -2
  109. package/dist/session/session.service.js +522 -41
  110. package/dist/session/session.types.d.ts +72 -0
  111. package/dist/session/session.types.js +74 -3
  112. package/dist/shared/format/format-bytes.d.ts +1 -0
  113. package/dist/shared/format/format-bytes.js +9 -0
  114. package/dist/shared/path/path.d.ts +1 -0
  115. package/dist/shared/path/path.js +21 -1
  116. package/dist/shared/process/dotnet-toolchain.d.ts +14 -0
  117. package/dist/shared/process/dotnet-toolchain.js +179 -0
  118. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  119. package/dist/shared/process/host-process-recovery.js +78 -0
  120. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  121. package/dist/shared/process/host-process-supervisor.js +330 -0
  122. package/dist/shared/process/process.d.ts +1 -0
  123. package/dist/shared/process/process.js +1 -0
  124. package/dist/shared/redaction/redaction.js +12 -2
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/dist/worktree/artifact-injector.d.ts +3 -2
  128. package/dist/worktree/artifact-injector.js +43 -4
  129. package/dist/worktree/dependency-warmer.d.ts +28 -1
  130. package/dist/worktree/dependency-warmer.js +308 -9
  131. package/dist/worktree/index.d.ts +2 -1
  132. package/dist/worktree/index.js +1 -0
  133. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  134. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  135. package/dist/worktree/worktree-paths.d.ts +4 -0
  136. package/dist/worktree/worktree-paths.js +30 -0
  137. package/dist/worktree/worktree.base.d.ts +3 -0
  138. package/dist/worktree/worktree.service.d.ts +13 -2
  139. package/dist/worktree/worktree.service.js +238 -47
  140. package/dist/worktree/worktree.types.d.ts +5 -1
  141. package/package.json +5 -3
  142. package/runtime/windows-job-supervisor.ps1 +444 -0
  143. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,10 +1,13 @@
1
+ import { createHash, randomUUID } from "node:crypto";
1
2
  import { existsSync, realpathSync } from "node:fs";
2
3
  import fs from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
6
+ import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
5
7
  import { NodeProcessRunner } from "../shared/process/process.js";
6
8
  import { buildWindowsCmdLaunch } from "../shared/process/windows-launch.js";
7
9
  import { isSubPath } from "../shared/path/path.js";
10
+ import { defaultWorktreeCoordinatorStateRoot } from "./worktree-paths.js";
8
11
  const SKIP_DIR_NAMES = new Set([
9
12
  ".git",
10
13
  "node_modules",
@@ -23,6 +26,10 @@ const NATIVE_MODULE_HINTS = [
23
26
  "better-sqlite3",
24
27
  "@swc/core"
25
28
  ];
29
+ const MAX_COMMAND_FAILURE_OUTPUT_LENGTH = 8_000;
30
+ const DEPENDENCY_RECEIPT_SCHEMA_VERSION = 1;
31
+ const DEPENDENCY_PREPARATION_CONTRACT = "managed-worktree-isolated-v1";
32
+ const dependencyPreparationCoordinator = new RepositoryCoordinatorLock(defaultWorktreeCoordinatorStateRoot());
26
33
  export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
27
34
  const entries = new Map();
28
35
  await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
@@ -50,15 +57,51 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
50
57
  const linked = [];
51
58
  const skipped = [];
52
59
  const fallbacks = [];
60
+ const receipts = [];
61
+ const restoredDotNetRoots = new Set();
62
+ const installedNodeRoots = new Set();
53
63
  for (const entry of warmPaths) {
54
64
  const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
55
65
  const targetPath = path.join(worktreePath, entry.relativePath);
56
66
  assertWithinRoot(allowedRoot, targetPath, "Warm target");
67
+ if (entry.ecosystem === "node" && path.basename(entry.relativePath) === "node_modules") {
68
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
69
+ const installPackageRoot = await resolveNodeInstallPackageRoot(worktreePath, entry.packageRoot);
70
+ const packageRoot = path.resolve(worktreePath, installPackageRoot === "." ? "" : installPackageRoot);
71
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
72
+ if (installedNodeRoots.has(packageKey)) {
73
+ skipped.push(entry.relativePath);
74
+ continue;
75
+ }
76
+ installedNodeRoots.add(packageKey);
77
+ const preparation = await ensureInstallFallback({ ...entry, packageRoot: installPackageRoot }, worktreePath, "Managed worktrees require isolated Node dependencies.", executor, request.preparationPolicy);
78
+ receipts.push(preparation.receipt);
79
+ if (preparation.install)
80
+ fallbacks.push(preparation.install);
81
+ continue;
82
+ }
83
+ if (entry.ecosystem === "dotnet") {
84
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
85
+ const packageRoot = path.resolve(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
86
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
87
+ if (restoredDotNetRoots.has(packageKey)) {
88
+ skipped.push(entry.relativePath);
89
+ continue;
90
+ }
91
+ restoredDotNetRoots.add(packageKey);
92
+ const preparation = await ensureInstallFallback(entry, worktreePath, "Managed worktrees require isolated .NET build outputs.", executor, request.preparationPolicy);
93
+ receipts.push(preparation.receipt);
94
+ if (preparation.install)
95
+ fallbacks.push(preparation.install);
96
+ continue;
97
+ }
57
98
  const sourceExists = await directoryExists(sourcePath);
58
99
  const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
59
100
  if (fallbackReason) {
60
- const install = await runInstallFallback(entry, worktreePath, fallbackReason, executor);
61
- fallbacks.push(install);
101
+ const preparation = await ensureInstallFallback(entry, worktreePath, fallbackReason, executor, request.preparationPolicy);
102
+ receipts.push(preparation.receipt);
103
+ if (preparation.install)
104
+ fallbacks.push(preparation.install);
62
105
  continue;
63
106
  }
64
107
  if (!sourceExists) {
@@ -68,7 +111,17 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
68
111
  await createDirectoryLink(sourcePath, targetPath, allowedRoot);
69
112
  linked.push(entry.relativePath);
70
113
  }
71
- return { linked, skipped, fallbacks };
114
+ return { linked, skipped, fallbacks, receipts };
115
+ }
116
+ async function removeLegacyDirectoryLink(targetPath, allowedRoot) {
117
+ const resolvedTarget = path.resolve(targetPath);
118
+ const resolvedRoot = path.resolve(allowedRoot);
119
+ assertWithinRoot(resolvedRoot, resolvedTarget, "Legacy warm target");
120
+ const stat = await fs.lstat(resolvedTarget).catch(() => null);
121
+ if (!stat?.isSymbolicLink()) {
122
+ return;
123
+ }
124
+ await fs.rm(resolvedTarget, { force: true });
72
125
  }
73
126
  export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
74
127
  const resolvedSource = path.resolve(sourcePath);
@@ -81,7 +134,10 @@ export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
81
134
  if (!stat?.isDirectory()) {
82
135
  throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
83
136
  }
84
- await fs.rm(resolvedTarget, { recursive: true, force: true });
137
+ const existingTarget = await fs.lstat(resolvedTarget).catch(() => null);
138
+ await fs.rm(resolvedTarget, existingTarget?.isSymbolicLink()
139
+ ? { force: true }
140
+ : { recursive: true, force: true });
85
141
  await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
86
142
  if (process.platform === "win32") {
87
143
  await fs.symlink(resolvedSource, resolvedTarget, "junction");
@@ -182,13 +238,217 @@ export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRoo
182
238
  }
183
239
  return false;
184
240
  }
241
+ async function ensureInstallFallback(entry, worktreePath, reason, executor, policy) {
242
+ const startedAt = performance.now();
243
+ const cwd = path.resolve(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
244
+ assertWithinRoot(path.resolve(worktreePath), cwd, "Dependency package root");
245
+ const command = await installFallbackCommand(entry, cwd);
246
+ const outputMarker = dependencyOutputMarker(entry.ecosystem, cwd);
247
+ const receiptPath = path.join(outputMarker, ".forge-dependency-preparation.json");
248
+ const fingerprint = await dependencyPreparationFingerprint(entry, worktreePath, cwd, command, policy);
249
+ return await dependencyPreparationCoordinator.withLock(receiptPath, async () => {
250
+ const existing = await readDependencyPreparationReceipt(receiptPath);
251
+ if (existing?.fingerprint === fingerprint
252
+ && existing.ecosystem === entry.ecosystem
253
+ && existing.packageRoot === entry.packageRoot
254
+ && existing.command === command
255
+ && await directoryExists(outputMarker)) {
256
+ return {
257
+ install: null,
258
+ receipt: {
259
+ ecosystem: entry.ecosystem,
260
+ packageRoot: entry.packageRoot,
261
+ command,
262
+ cacheHit: true,
263
+ fingerprint,
264
+ receiptPath,
265
+ durationMs: elapsedMilliseconds(startedAt),
266
+ },
267
+ };
268
+ }
269
+ const install = await runInstallFallback(entry, worktreePath, reason, executor);
270
+ // npm is allowed to omit node_modules when the package graph is empty. Keep
271
+ // an isolated, ignored output directory so the durable receipt still has a
272
+ // concrete marker whose removal invalidates reuse.
273
+ if (entry.ecosystem === "node") {
274
+ await fs.mkdir(outputMarker, { recursive: true });
275
+ }
276
+ if (!(await directoryExists(outputMarker))) {
277
+ throw new Error(`dependency_preparation_output_missing: ${install.command} succeeded without creating ${outputMarker}.`);
278
+ }
279
+ const receipt = {
280
+ schemaVersion: DEPENDENCY_RECEIPT_SCHEMA_VERSION,
281
+ fingerprint,
282
+ ecosystem: entry.ecosystem,
283
+ packageRoot: entry.packageRoot,
284
+ command,
285
+ outputMarker: path.relative(cwd, outputMarker).replaceAll("\\", "/"),
286
+ completedAt: new Date().toISOString(),
287
+ };
288
+ await persistDependencyPreparationReceipt(receiptPath, receipt);
289
+ return {
290
+ install,
291
+ receipt: {
292
+ ecosystem: entry.ecosystem,
293
+ packageRoot: entry.packageRoot,
294
+ command,
295
+ cacheHit: false,
296
+ fingerprint,
297
+ receiptPath,
298
+ durationMs: elapsedMilliseconds(startedAt),
299
+ },
300
+ };
301
+ });
302
+ }
303
+ function elapsedMilliseconds(startedAt) {
304
+ return Math.max(0, Math.round((performance.now() - startedAt) * 1000) / 1000);
305
+ }
306
+ async function installFallbackCommand(entry, cwd) {
307
+ if (entry.ecosystem === "node") {
308
+ if (await pathExists(path.join(cwd, "pnpm-lock.yaml")))
309
+ return "pnpm install --frozen-lockfile";
310
+ return await pathExists(path.join(cwd, "package-lock.json")) ? "npm ci" : "npm install";
311
+ }
312
+ if (entry.ecosystem === "python") {
313
+ return await pathExists(path.join(cwd, "requirements.txt"))
314
+ ? "python -m venv .venv && pip install -r requirements.txt"
315
+ : "python -m venv .venv";
316
+ }
317
+ return "dotnet restore";
318
+ }
319
+ function dependencyOutputMarker(ecosystem, cwd) {
320
+ if (ecosystem === "node")
321
+ return path.join(cwd, "node_modules");
322
+ if (ecosystem === "python")
323
+ return path.join(cwd, ".venv");
324
+ return path.join(cwd, "obj");
325
+ }
326
+ async function dependencyPreparationFingerprint(entry, worktreePath, cwd, command, policy) {
327
+ const inputFiles = await dependencyInputFiles(entry.ecosystem, worktreePath, cwd);
328
+ const inputDigests = await Promise.all(inputFiles.map(async (file) => ({
329
+ path: path.relative(worktreePath, file).replaceAll("\\", "/"),
330
+ digest: await sha256File(file),
331
+ })));
332
+ const toolchainVersion = await dependencyToolchainVersion(entry.ecosystem, cwd, policy);
333
+ const trustDomain = policy?.trustDomain ?? process.env.FORGE_TRUST_DOMAIN_ID ?? "default";
334
+ const invalidationEpoch = policy?.invalidationEpoch ?? process.env.FORGE_DEPENDENCY_CACHE_EPOCH ?? "default";
335
+ const contract = {
336
+ schemaVersion: DEPENDENCY_RECEIPT_SCHEMA_VERSION,
337
+ preparationContract: DEPENDENCY_PREPARATION_CONTRACT,
338
+ ecosystem: entry.ecosystem,
339
+ packageRoot: entry.packageRoot.replaceAll("\\", "/"),
340
+ command,
341
+ inputDigests,
342
+ toolchainVersion,
343
+ nodeRuntime: process.version,
344
+ platform: process.platform,
345
+ architecture: process.arch,
346
+ osRelease: os.release(),
347
+ isolation: policy?.isolation ?? process.env.FORGE_RUNTIME_MODE ?? "managed_host_process",
348
+ trustDomainFingerprint: sha256Text(trustDomain),
349
+ invalidationEpochFingerprint: sha256Text(invalidationEpoch),
350
+ };
351
+ return `sha256:${sha256Text(JSON.stringify(contract))}`;
352
+ }
353
+ async function dependencyInputFiles(ecosystem, worktreePath, cwd) {
354
+ const result = new Set();
355
+ const directNames = ecosystem === "node"
356
+ ? ["package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "yarn.lock", ".npmrc", ".yarnrc", ".yarnrc.yml", ".nvmrc", ".node-version"]
357
+ : ecosystem === "python"
358
+ ? ["pyproject.toml", "requirements.txt", "requirements-dev.txt", "poetry.lock", "Pipfile", "Pipfile.lock", "uv.lock", "pip.conf", "pyvenv.cfg"]
359
+ : ["global.json", "NuGet.Config", "nuget.config", "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "packages.lock.json"];
360
+ let current = cwd;
361
+ const root = path.resolve(worktreePath);
362
+ while (isSubPath(root, current)) {
363
+ for (const name of directNames) {
364
+ const candidate = path.join(current, name);
365
+ if (await pathExists(candidate))
366
+ result.add(candidate);
367
+ }
368
+ if (ecosystem === "dotnet" && current === cwd) {
369
+ const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
370
+ for (const item of entries) {
371
+ if (item.isFile() && (/\.(?:csproj|fsproj|vbproj|sln|slnx)$/iu.test(item.name))) {
372
+ result.add(path.join(current, item.name));
373
+ }
374
+ }
375
+ }
376
+ if (current === root)
377
+ break;
378
+ current = path.dirname(current);
379
+ }
380
+ return [...result].sort((left, right) => left.localeCompare(right));
381
+ }
382
+ async function dependencyToolchainVersion(ecosystem, cwd, policy) {
383
+ if (ecosystem === "node") {
384
+ const manager = await pathExists(path.join(cwd, "pnpm-lock.yaml")) ? "pnpm" : "npm";
385
+ const override = policy?.toolchainVersions?.[manager];
386
+ if (override)
387
+ return `${manager}@${override};node@${process.version}`;
388
+ const launch = manager === "pnpm" ? resolvePnpmLaunch(["--version"]) : resolveNpmLaunch(["--version"]);
389
+ return `${manager}@${await readToolVersion(cwd, launch.command, launch.args)};node@${process.version}`;
390
+ }
391
+ const override = policy?.toolchainVersions?.[ecosystem];
392
+ if (override)
393
+ return `${ecosystem}@${override}`;
394
+ const launch = ecosystem === "dotnet"
395
+ ? { command: "dotnet", args: ["--version"] }
396
+ : { command: "python", args: ["--version"] };
397
+ return `${ecosystem}@${await readToolVersion(cwd, launch.command, launch.args)}`;
398
+ }
399
+ async function readToolVersion(cwd, command, args) {
400
+ return await defaultProcessRunner.run({ command, cwd, args })
401
+ .then(result => result.exitCode === 0
402
+ ? (result.stdout.trim() || result.stderr.trim() || "unknown").slice(0, 256)
403
+ : `unavailable:${result.exitCode}`)
404
+ .catch(() => "unavailable");
405
+ }
406
+ async function readDependencyPreparationReceipt(receiptPath) {
407
+ try {
408
+ const parsed = JSON.parse(await fs.readFile(receiptPath, "utf8"));
409
+ return parsed.schemaVersion === DEPENDENCY_RECEIPT_SCHEMA_VERSION
410
+ && typeof parsed.fingerprint === "string"
411
+ && typeof parsed.ecosystem === "string"
412
+ && typeof parsed.packageRoot === "string"
413
+ && typeof parsed.command === "string"
414
+ && typeof parsed.outputMarker === "string"
415
+ ? parsed
416
+ : null;
417
+ }
418
+ catch (error) {
419
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
420
+ return null;
421
+ throw error;
422
+ }
423
+ }
424
+ async function persistDependencyPreparationReceipt(receiptPath, receipt) {
425
+ await fs.mkdir(path.dirname(receiptPath), { recursive: true });
426
+ const temporaryPath = `${receiptPath}.${randomUUID()}.tmp`;
427
+ await fs.writeFile(temporaryPath, `${JSON.stringify(receipt, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
428
+ try {
429
+ await fs.rename(temporaryPath, receiptPath);
430
+ }
431
+ finally {
432
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
433
+ }
434
+ }
435
+ function sha256Text(value) {
436
+ return createHash("sha256").update(value).digest("hex");
437
+ }
185
438
  async function runInstallFallback(entry, worktreePath, reason, executor) {
186
439
  const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
187
440
  if (entry.ecosystem === "node") {
441
+ const pnpmLockPath = path.join(cwd, "pnpm-lock.yaml");
442
+ if (await pathExists(pnpmLockPath)) {
443
+ const args = ["install", "--frozen-lockfile"];
444
+ const launch = resolvePnpmLaunch(args);
445
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
446
+ return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `pnpm ${args.join(" ")}` };
447
+ }
188
448
  const lockPath = path.join(cwd, "package-lock.json");
189
449
  const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
190
450
  const launch = resolveNpmLaunch(args);
191
- await executor.run(cwd, launch.command, launch.args);
451
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
192
452
  return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
193
453
  }
194
454
  if (entry.ecosystem === "python") {
@@ -219,6 +479,23 @@ async function runInstallFallback(entry, worktreePath, reason, executor) {
219
479
  command: "dotnet restore"
220
480
  };
221
481
  }
482
+ async function resolveNodeInstallPackageRoot(worktreePath, packageRoot) {
483
+ const resolvedWorktree = path.resolve(worktreePath);
484
+ let current = path.resolve(resolvedWorktree, packageRoot === "." ? "" : packageRoot);
485
+ assertWithinRoot(resolvedWorktree, current, "Node package root");
486
+ while (isSubPath(resolvedWorktree, current)) {
487
+ if (await pathExists(path.join(current, "pnpm-lock.yaml"))
488
+ || await pathExists(path.join(current, "pnpm-workspace.yaml"))
489
+ || await pathExists(path.join(current, "package-lock.json"))) {
490
+ const relative = normalizeRelativePath(path.relative(resolvedWorktree, current));
491
+ return relative || ".";
492
+ }
493
+ if (current === resolvedWorktree)
494
+ break;
495
+ current = path.dirname(current);
496
+ }
497
+ return packageRoot;
498
+ }
222
499
  export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
223
500
  return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
224
501
  }
@@ -294,7 +571,7 @@ export function resolveNpmLaunch(args, options = {}) {
294
571
  const env = options.env ?? process.env;
295
572
  const execPath = options.execPath ?? process.execPath;
296
573
  const npmExecPath = env.npm_execpath;
297
- if (npmExecPath) {
574
+ if (npmExecPath && path.basename(npmExecPath).toLowerCase() === "npm-cli.js") {
298
575
  return { command: execPath, args: [npmExecPath, ...args] };
299
576
  }
300
577
  const platform = options.platform ?? process.platform;
@@ -310,6 +587,26 @@ export function resolveNpmLaunch(args, options = {}) {
310
587
  }
311
588
  return { command: "npm", args };
312
589
  }
590
+ export function resolvePnpmLaunch(args) {
591
+ if (process.platform === "win32") {
592
+ return buildWindowsCmdLaunch("pnpm.cmd", args, process.env.ComSpec);
593
+ }
594
+ return { command: "pnpm", args };
595
+ }
596
+ export function formatCommandFailureOutput(stdout, stderr) {
597
+ const sections = [
598
+ stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
599
+ stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
600
+ ].filter((section) => section !== null);
601
+ const output = sections.join("\n");
602
+ if (output.length <= MAX_COMMAND_FAILURE_OUTPUT_LENGTH) {
603
+ return output;
604
+ }
605
+ return `[last ${MAX_COMMAND_FAILURE_OUTPUT_LENGTH} characters]\n${output.slice(-MAX_COMMAND_FAILURE_OUTPUT_LENGTH)}`;
606
+ }
607
+ export function unattendedPackageManagerEnvironment(env = process.env) {
608
+ return { ...env, CI: "true" };
609
+ }
313
610
  function tryResolveNpmCandidate(candidate) {
314
611
  try {
315
612
  if (!existsSync(candidate)) {
@@ -331,10 +628,12 @@ function defaultNpmCliCandidates(execPath, homeDir) {
331
628
  return candidates;
332
629
  }
333
630
  const defaultCommandExecutor = {
334
- async run(cwd, command, args) {
335
- const result = await defaultProcessRunner.run({ command, cwd, args });
631
+ async run(cwd, command, args, options) {
632
+ const result = await defaultProcessRunner.run({ command, cwd, args, env: options?.env });
336
633
  if (result.exitCode !== 0) {
337
- throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr.trim()}`);
634
+ const output = formatCommandFailureOutput(result.stdout, result.stderr);
635
+ const details = output.length > 0 ? `\n${output}` : " no output was captured";
636
+ throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}) in ${cwd}:${details}`);
338
637
  }
339
638
  }
340
639
  };
@@ -1,4 +1,5 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
2
3
  export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
3
- export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } from "./dependency-warmer.js";
4
+ export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult, DependencyPreparationPolicy, DependencyPreparationReceiptResult, } from "./dependency-warmer.js";
4
5
  export type { WorktreeService } from "./worktree.base.js";
@@ -1,2 +1,3 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
2
3
  export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
@@ -0,0 +1,2 @@
1
+ import type { DispatchPriorPhaseArtifact } from "../session/session.types.js";
2
+ export declare function hydratePriorPhaseArtifacts(worktreePath: string, artifacts?: readonly DispatchPriorPhaseArtifact[]): Promise<number>;
@@ -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,17 @@
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 repoCreationCoordinationPath;
8
11
  release(handle: WorktreeHandle): Promise<void>;
12
+ private restoreOrphanedWorktree;
9
13
  private resolveRepoPath;
14
+ private managedRepoPath;
10
15
  private findExistingRepoPath;
11
16
  private resolveMatchingRepo;
12
17
  private resolveBaseStartPoint;
@@ -15,4 +20,10 @@ export declare class GitWorktreeService implements WorktreeService {
15
20
  private currentBranch;
16
21
  private ensureGitIdentity;
17
22
  }
23
+ type WorktreeRename = (source: string, destination: string) => Promise<void>;
24
+ type WorktreeRenameDelay = (milliseconds: number) => Promise<void>;
25
+ export declare function renameWorktreeForRelease(source: string, destination: string, rename?: WorktreeRename, wait?: WorktreeRenameDelay, maxAttempts?: number): Promise<void>;
26
+ export declare function requiresMutableEnvironmentPreparation(request: Pick<WorktreePrepareRequest, "applicationRuntimeRequired">): boolean;
27
+ export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
18
28
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
29
+ export {};