@loucompanion/forge-bridge 0.1.3-dev.ba7d36726eda → 0.1.3-dev.bca7d0ccee53

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 (73) hide show
  1. package/dist/bridge/bridge.base.d.ts +2 -1
  2. package/dist/bridge/bridge.service.d.ts +8 -0
  3. package/dist/bridge/bridge.service.js +184 -12
  4. package/dist/bridge/bridge.types.d.ts +37 -0
  5. package/dist/cli/cli.help.js +1 -1
  6. package/dist/cli/cli.service.js +1 -1
  7. package/dist/cli/local-config.js +2 -1
  8. package/dist/runtime/attempt-node-runtime-launcher.d.ts +3 -1
  9. package/dist/runtime/attempt-node-runtime-launcher.js +62 -11
  10. package/dist/runtime/attempt-process-supervisor-registry.js +1 -1
  11. package/dist/runtime/attempt-process-supervisor.d.ts +12 -0
  12. package/dist/runtime/attempt-process-supervisor.js +75 -15
  13. package/dist/runtime/generated-output-reset.d.ts +10 -0
  14. package/dist/runtime/generated-output-reset.js +103 -0
  15. package/dist/runtime/host-listener-inspector.d.ts +16 -0
  16. package/dist/runtime/host-listener-inspector.js +143 -4
  17. package/dist/runtime/host-runtime-contract.d.ts +9 -1
  18. package/dist/runtime/host-runtime-contract.js +411 -6
  19. package/dist/runtime/host-runtime-lease.d.ts +12 -1
  20. package/dist/runtime/host-runtime-lease.js +271 -50
  21. package/dist/runtime/host-runtime-promotion.d.ts +2 -0
  22. package/dist/runtime/host-runtime-promotion.js +35 -4
  23. package/dist/runtime/node-host-runtime-adapter.d.ts +7 -0
  24. package/dist/runtime/node-host-runtime-adapter.js +71 -8
  25. package/dist/runtime/playwright-host-runtime-adapter.js +84 -0
  26. package/dist/sandbox/preflight.d.ts +9 -0
  27. package/dist/sandbox/preflight.js +39 -4
  28. package/dist/sandbox/workspace.service.js +7 -2
  29. package/dist/session/managed-attempt-tools.d.ts +17 -0
  30. package/dist/session/managed-attempt-tools.js +712 -0
  31. package/dist/session/phase-completion.artifacts.js +9 -2
  32. package/dist/session/phase-completion.d.ts +11 -2
  33. package/dist/session/phase-completion.helpers.d.ts +5 -0
  34. package/dist/session/phase-completion.helpers.js +45 -9
  35. package/dist/session/phase-completion.js +12 -3
  36. package/dist/session/phase-completion.prompts.d.ts +3 -3
  37. package/dist/session/phase-completion.prompts.js +21 -6
  38. package/dist/session/phase-completion.runtime.d.ts +13 -1
  39. package/dist/session/phase-completion.runtime.js +183 -8
  40. package/dist/session/phase-completion.schemas.js +108 -3
  41. package/dist/session/phase-completion.validation.d.ts +4 -1
  42. package/dist/session/phase-completion.validation.js +144 -18
  43. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  44. package/dist/session/provider-cli/ci-repair-readiness.js +95 -0
  45. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  46. package/dist/session/provider-cli/provider-cli.process.d.ts +14 -0
  47. package/dist/session/provider-cli/provider-cli.process.js +176 -18
  48. package/dist/session/provider-cli/provider-cli.prompts.js +30 -8
  49. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  50. package/dist/session/provider-cli/provider-cli.service.d.ts +19 -2
  51. package/dist/session/provider-cli/provider-cli.service.js +390 -83
  52. package/dist/session/provider-cli/provider-cli.types.d.ts +3 -0
  53. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  54. package/dist/session/provider-cli/provider-output-state.js +30 -2
  55. package/dist/session/session.base.d.ts +2 -1
  56. package/dist/session/session.service.d.ts +2 -1
  57. package/dist/session/session.service.js +303 -30
  58. package/dist/session/session.types.d.ts +38 -0
  59. package/dist/session/session.types.js +17 -0
  60. package/dist/shared/process/host-process-supervisor.d.ts +18 -1
  61. package/dist/shared/process/host-process-supervisor.js +94 -7
  62. package/dist/shared/process/process.d.ts +1 -0
  63. package/dist/shared/process/process.js +1 -0
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/dist/worktree/artifact-injector.d.ts +3 -2
  67. package/dist/worktree/artifact-injector.js +43 -4
  68. package/dist/worktree/dependency-warmer.d.ts +10 -1
  69. package/dist/worktree/dependency-warmer.js +94 -5
  70. package/dist/worktree/worktree.service.d.ts +1 -0
  71. package/dist/worktree/worktree.service.js +12 -1
  72. package/package.json +1 -1
  73. package/runtime/windows-job-supervisor.ps1 +89 -20
@@ -13,8 +13,21 @@ export interface HostProcessSupervisedLaunch {
13
13
  readonly supervisorId: string;
14
14
  readonly strategy: HostProcessSupervisorStrategy;
15
15
  readonly metadataPath: string;
16
+ readonly payloadPath?: string;
17
+ readonly readyPath?: string;
18
+ readonly startGrantPath?: string;
16
19
  readonly commandDigest: string;
17
20
  }
21
+ export interface HostProcessProviderReady {
22
+ readonly schemaVersion: 1;
23
+ readonly supervisorId: string;
24
+ readonly rootPid?: number;
25
+ readonly rootIdentity?: string | null;
26
+ readonly providerPid: number;
27
+ readonly providerCreatedAt: string;
28
+ readonly providerIdentity?: string | null;
29
+ readonly startAttempt: number;
30
+ }
18
31
  export interface HostProcessSupervisorMetadata {
19
32
  readonly schemaVersion: 1;
20
33
  readonly supervisorId: string;
@@ -28,9 +41,13 @@ export interface HostProcessSupervisorMetadata {
28
41
  readonly dispatchAttemptId: string;
29
42
  readonly fencingToken: string;
30
43
  readonly bridgePid: number;
44
+ readonly providerPid?: number;
45
+ readonly providerCreatedAt?: string;
46
+ readonly providerIdentity?: string | null;
47
+ readonly providerStartAttempt?: number;
31
48
  }
32
49
  export declare function resolveHostProcessSupervisedLaunch(request: HostProcessSupervisionRequest, resolvedCommand: string, resolvedArgs: readonly string[], platform?: NodeJS.Platform): HostProcessSupervisedLaunch | null;
33
- export declare function persistHostProcessSupervisorMetadata(launch: HostProcessSupervisedLaunch, request: HostProcessSupervisionRequest, rootPid: number, rootCreatedAt: string): HostProcessSupervisorMetadata;
50
+ export declare function persistHostProcessSupervisorMetadata(launch: HostProcessSupervisedLaunch, request: HostProcessSupervisionRequest, rootPid: number, rootCreatedAt: string, providerReady?: HostProcessProviderReady): HostProcessSupervisorMetadata;
34
51
  /**
35
52
  * Captures an OS-issued process birth marker. Recovery compares this marker
36
53
  * before signalling a persisted PID so PID reuse can never redirect cleanup
@@ -19,13 +19,22 @@ export function resolveHostProcessSupervisedLaunch(request, resolvedCommand, res
19
19
  if (!fs.existsSync(windowsJobSupervisorPath)) {
20
20
  throw new Error(`host_process_supervisor_missing: ${windowsJobSupervisorPath}`);
21
21
  }
22
- const payload = Buffer.from(JSON.stringify({
22
+ const payloadPath = `${metadataPath}.launch.json`;
23
+ const readyPath = `${metadataPath}.provider-ready.json`;
24
+ const startGrantPath = `${metadataPath}.provider-start-granted`;
25
+ fs.writeFileSync(payloadPath, `${JSON.stringify({
23
26
  command: resolvedCommand,
24
27
  args: resolvedArgs,
25
28
  cwd: request.cwd,
26
29
  parentPid: process.pid,
27
30
  supervisorId,
28
- }), "utf8").toString("base64");
31
+ readyPath,
32
+ startGrantPath,
33
+ })}\n`, {
34
+ encoding: "utf8",
35
+ flag: "wx",
36
+ mode: 0o600,
37
+ });
29
38
  return {
30
39
  command: windowsPowerShellPath(),
31
40
  args: [
@@ -38,13 +47,16 @@ export function resolveHostProcessSupervisedLaunch(request, resolvedCommand, res
38
47
  windowsJobSupervisorPath,
39
48
  "-Mode",
40
49
  "Outer",
41
- "-Payload",
42
- payload,
50
+ "-PayloadPath",
51
+ payloadPath,
43
52
  ],
44
53
  detached: false,
45
54
  supervisorId,
46
55
  strategy: "windows_job_object",
47
56
  metadataPath,
57
+ payloadPath,
58
+ readyPath,
59
+ startGrantPath,
48
60
  commandDigest,
49
61
  };
50
62
  }
@@ -58,7 +70,7 @@ export function resolveHostProcessSupervisedLaunch(request, resolvedCommand, res
58
70
  commandDigest,
59
71
  };
60
72
  }
61
- export function persistHostProcessSupervisorMetadata(launch, request, rootPid, rootCreatedAt) {
73
+ export function persistHostProcessSupervisorMetadata(launch, request, rootPid, rootCreatedAt, providerReady) {
62
74
  if (!Number.isSafeInteger(rootPid) || rootPid <= 0) {
63
75
  throw new Error("host_process_supervisor_invalid: supervised root PID was not assigned.");
64
76
  }
@@ -69,13 +81,20 @@ export function persistHostProcessSupervisorMetadata(launch, request, rootPid, r
69
81
  strategy: launch.strategy,
70
82
  rootPid,
71
83
  rootCreatedAt,
72
- rootIdentity: captureHostProcessIdentity(rootPid),
84
+ rootIdentity: providerReady?.rootPid === rootPid
85
+ ? providerReady.rootIdentity ?? null
86
+ : captureHostProcessIdentity(rootPid),
73
87
  commandDigest: launch.commandDigest,
74
88
  worktreePath: request.cwd,
75
89
  runtimeLeaseId: requiredEnvironment(env, "FORGE_RESOURCE_LEASE_ID"),
76
90
  dispatchAttemptId: requiredEnvironment(env, "FORGE_DISPATCH_ATTEMPT_ID"),
77
91
  fencingToken: requiredEnvironment(env, "FORGE_FENCING_TOKEN"),
78
92
  bridgePid: process.pid,
93
+ providerPid: providerReady?.providerPid,
94
+ providerCreatedAt: providerReady?.providerCreatedAt,
95
+ providerIdentity: providerReady?.providerIdentity
96
+ ?? (providerReady ? captureHostProcessIdentity(providerReady.providerPid) : undefined),
97
+ providerStartAttempt: providerReady?.startAttempt,
79
98
  };
80
99
  fs.writeFileSync(launch.metadataPath, `${JSON.stringify(metadata)}\n`, {
81
100
  encoding: "utf8",
@@ -113,7 +132,7 @@ export function captureHostProcessIdentity(pid, platform = process.platform) {
113
132
  ], {
114
133
  encoding: "utf8",
115
134
  windowsHide: true,
116
- timeout: 2_000,
135
+ timeout: 10_000,
117
136
  stdio: ["ignore", "pipe", "ignore"],
118
137
  }).trim();
119
138
  return /^\d+$/u.test(output) ? `win32:${output}` : null;
@@ -137,12 +156,34 @@ export async function terminateHostProcessTree(child, launch, graceMs = 2_000) {
137
156
  return;
138
157
  }
139
158
  if (launch.strategy === "windows_job_object") {
159
+ const rootPid = child.pid ?? 0;
160
+ const metadata = readSupervisorMetadata(launch.metadataPath);
161
+ const rootIdentity = metadata?.rootPid === rootPid
162
+ ? metadata.rootIdentity ?? null
163
+ : captureHostProcessIdentity(rootPid, "win32");
140
164
  try {
141
165
  child.kill("SIGTERM");
142
166
  }
143
167
  catch {
144
168
  // Closing the launcher is best effort when the Job Object already settled.
145
169
  }
170
+ await waitForExactProcessExit(rootPid, rootIdentity, graceMs, "win32");
171
+ if (exactProcessExists(rootPid, rootIdentity, "win32")) {
172
+ forceWindowsProcessTree(rootPid);
173
+ await waitForExactProcessExit(rootPid, rootIdentity, graceMs, "win32");
174
+ }
175
+ if (exactProcessExists(rootPid, rootIdentity, "win32")) {
176
+ throw new Error("host_process_supervisor_termination_failed: supervised Windows root did not stop.");
177
+ }
178
+ const providerPid = metadata?.providerPid ?? 0;
179
+ const providerIdentity = metadata?.providerIdentity ?? null;
180
+ if (exactProcessExists(providerPid, providerIdentity, "win32")) {
181
+ forceWindowsProcessTree(providerPid);
182
+ await waitForExactProcessExit(providerPid, providerIdentity, graceMs, "win32");
183
+ }
184
+ if (exactProcessExists(providerPid, providerIdentity, "win32")) {
185
+ throw new Error("host_process_supervisor_termination_failed: supervised Windows provider tree did not stop.");
186
+ }
146
187
  return;
147
188
  }
148
189
  const processGroupId = child.pid;
@@ -193,3 +234,49 @@ function unixProcessGroupExists(processGroupId) {
193
234
  throw error;
194
235
  }
195
236
  }
237
+ function readSupervisorMetadata(metadataPath) {
238
+ try {
239
+ return JSON.parse(fs.readFileSync(metadataPath, "utf8"));
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ function exactProcessExists(pid, identity, platform) {
246
+ if (!identity || !processExists(pid))
247
+ return false;
248
+ return captureHostProcessIdentity(pid, platform) === identity;
249
+ }
250
+ function processExists(pid) {
251
+ if (!Number.isSafeInteger(pid) || pid <= 0)
252
+ return false;
253
+ try {
254
+ process.kill(pid, 0);
255
+ return true;
256
+ }
257
+ catch (error) {
258
+ return error.code === "EPERM";
259
+ }
260
+ }
261
+ async function waitForExactProcessExit(pid, identity, timeoutMs, platform) {
262
+ const deadline = Date.now() + timeoutMs;
263
+ while (Date.now() < deadline && exactProcessExists(pid, identity, platform)) {
264
+ await new Promise(resolve => setTimeout(resolve, 25));
265
+ }
266
+ }
267
+ function forceWindowsProcessTree(pid) {
268
+ if (!Number.isSafeInteger(pid) || pid <= 0)
269
+ return;
270
+ const systemRoot = process.env.SystemRoot?.trim() || "C:\\Windows";
271
+ const taskkill = path.win32.join(systemRoot, "System32", "taskkill.exe");
272
+ try {
273
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], {
274
+ windowsHide: true,
275
+ timeout: 5_000,
276
+ stdio: "ignore",
277
+ });
278
+ }
279
+ catch {
280
+ // A racing process exit is verified by the caller before it returns.
281
+ }
282
+ }
@@ -2,6 +2,7 @@ export interface ProcessRunRequest {
2
2
  readonly command: string;
3
3
  readonly args: readonly string[];
4
4
  readonly cwd: string;
5
+ readonly env?: NodeJS.ProcessEnv;
5
6
  }
6
7
  export interface ProcessRunResult {
7
8
  readonly exitCode: number;
@@ -5,6 +5,7 @@ export class NodeProcessRunner {
5
5
  return await new Promise((resolve, reject) => {
6
6
  const child = spawn(request.command, request.args, {
7
7
  cwd: request.cwd,
8
+ env: request.env,
8
9
  stdio: ["ignore", "pipe", "pipe"]
9
10
  });
10
11
  let stdout = "";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.3-dev.ba7d36726eda";
1
+ export declare const forgeBridgeVersion = "0.1.3-dev.bca7d0ccee53";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.3-dev.ba7d36726eda";
1
+ export const forgeBridgeVersion = "0.1.3-dev.bca7d0ccee53";
@@ -1,9 +1,10 @@
1
1
  import type { DispatchResolvedArtifact } from "../session/session.types.js";
2
- export declare function injectWorktreeArtifacts(worktreePath: string, artifacts: readonly DispatchResolvedArtifact[]): Promise<void>;
2
+ export declare function injectWorktreeArtifacts(worktreePath: string, artifacts: readonly DispatchResolvedArtifact[], protectedRelativePaths?: ReadonlySet<string>): Promise<void>;
3
3
  export declare function filterArtifactsForLane(artifacts: readonly DispatchResolvedArtifact[], lane: string, phase: string): readonly DispatchResolvedArtifact[];
4
- export declare function writeArtifact(worktreePath: string, artifact: DispatchResolvedArtifact): Promise<void>;
4
+ export declare function writeArtifact(worktreePath: string, artifact: DispatchResolvedArtifact, protectedRelativePaths?: ReadonlySet<string>): Promise<void>;
5
5
  export declare function selectLaneCommand(artifacts: readonly DispatchResolvedArtifact[], lane: string, phase: string): DispatchResolvedArtifact | undefined;
6
6
  export declare function resolveCommandSlug(lane: string, phase: string): string;
7
+ export declare function artifactExecutionBody(artifact: DispatchResolvedArtifact): string;
7
8
  export declare function buildSpecBody(laneCommand: DispatchResolvedArtifact, workItem: {
8
9
  readonly id: string;
9
10
  readonly title: string;
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { spawn } from "node:child_process";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  const DEBUG_OPERATIONAL_SKILLS = new Set(["debug-repro", "debug-root-cause", "debug-fix", "debug-regression"]);
@@ -59,9 +60,9 @@ const DRAFT_WORK_ITEM_OPERATIONAL_HOOKS = new Set([
59
60
  const DRAFT_WORK_ORDER_OPERATIONAL_HOOKS = new Set([
60
61
  "draft-work-order-plan"
61
62
  ]);
62
- export async function injectWorktreeArtifacts(worktreePath, artifacts) {
63
+ export async function injectWorktreeArtifacts(worktreePath, artifacts, protectedRelativePaths = new Set()) {
63
64
  for (const artifact of artifacts) {
64
- await writeArtifact(worktreePath, artifact);
65
+ await writeArtifact(worktreePath, artifact, protectedRelativePaths);
65
66
  }
66
67
  }
67
68
  export function filterArtifactsForLane(artifacts, lane, phase) {
@@ -78,7 +79,7 @@ export function filterArtifactsForLane(artifacts, lane, phase) {
78
79
  }
79
80
  return artifacts;
80
81
  }
81
- export async function writeArtifact(worktreePath, artifact) {
82
+ export async function writeArtifact(worktreePath, artifact, protectedRelativePaths = new Set()) {
82
83
  const writtenHash = sha256(artifact.body);
83
84
  if (writtenHash !== artifact.contentHash.toLowerCase()) {
84
85
  throw new Error(`Injected artifact '${artifact.slug}' content_hash mismatch (expected ${artifact.contentHash}, got ${writtenHash}).`);
@@ -93,6 +94,11 @@ export async function writeArtifact(worktreePath, artifact) {
93
94
  ? path.resolve(scopeDir, slug, "SKILL.md")
94
95
  : path.resolve(scopeDir, `${slug}.md`);
95
96
  assertInside(forgeRoot, filePath, "Artifact file");
97
+ const relativePath = path.relative(worktreePath, filePath).replaceAll(path.sep, "/");
98
+ if (protectedRelativePaths.has(relativePath)) {
99
+ await restoreTrackedArtifactIfItMatchesManagedBody(worktreePath, filePath, relativePath, artifact);
100
+ return;
101
+ }
96
102
  await fs.mkdir(path.dirname(filePath), { recursive: true });
97
103
  await fs.writeFile(filePath, artifact.body, "utf8");
98
104
  }
@@ -120,6 +126,39 @@ export function resolveCommandSlug(lane, phase) {
120
126
  return "dev-prompt";
121
127
  }
122
128
  }
129
+ async function restoreTrackedArtifactIfItMatchesManagedBody(worktreePath, filePath, relativePath, artifact) {
130
+ const current = await fs.readFile(filePath, "utf8").catch(() => null);
131
+ if (current === null
132
+ || (current !== artifact.body && current !== artifactExecutionBody(artifact)))
133
+ return;
134
+ const result = await new Promise((resolve) => {
135
+ const child = spawn("git", ["show", `HEAD:${relativePath}`], {
136
+ cwd: worktreePath,
137
+ windowsHide: true,
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ });
140
+ let stdout = "";
141
+ child.stdout?.setEncoding("utf8");
142
+ child.stdout?.on("data", chunk => { stdout += chunk; });
143
+ child.on("error", () => resolve({ exitCode: 1, stdout: "" }));
144
+ child.on("close", code => resolve({ exitCode: code ?? 1, stdout }));
145
+ });
146
+ if (result.exitCode === 0)
147
+ await fs.writeFile(filePath, result.stdout, "utf8");
148
+ }
149
+ export function artifactExecutionBody(artifact) {
150
+ const content = artifact.body;
151
+ if (!content.startsWith("---"))
152
+ return content;
153
+ const end = content.indexOf("\n---", 3);
154
+ if (end < 3)
155
+ return content;
156
+ let bodyStart = end + 4;
157
+ while (bodyStart < content.length && (content[bodyStart] === "\n" || content[bodyStart] === "\r")) {
158
+ bodyStart += 1;
159
+ }
160
+ return content.slice(bodyStart);
161
+ }
123
162
  function shouldInjectDebugArtifact(artifact, commandSlug) {
124
163
  if (artifact.scope.trim().toLowerCase() !== "operational") {
125
164
  return false;
@@ -192,7 +231,7 @@ function normalizeDraftPhase(phase) {
192
231
  return phase.trim().toLowerCase().replaceAll("-", "_");
193
232
  }
194
233
  export function buildSpecBody(laneCommand, workItem, phase = "implementation") {
195
- const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
234
+ const prompt = artifactExecutionBody(laneCommand).replaceAll("{workItemId}", workItem.id);
196
235
  return `---
197
236
  work_item_id: ${workItem.id}
198
237
  status: approved
@@ -22,7 +22,10 @@ export interface WarmDependenciesResult {
22
22
  readonly fallbacks: readonly InstallFallbackResult[];
23
23
  }
24
24
  export interface CommandExecutor {
25
- run(cwd: string, command: string, args: readonly string[]): Promise<void>;
25
+ run(cwd: string, command: string, args: readonly string[], options?: CommandExecutionOptions): Promise<void>;
26
+ }
27
+ export interface CommandExecutionOptions {
28
+ readonly env?: NodeJS.ProcessEnv;
26
29
  }
27
30
  export declare function detectWarmPaths(repoRoot: string, warmPathOverrides?: readonly string[]): Promise<WarmPathEntry[]>;
28
31
  export declare function warmWorktreeDependencies(request: WarmDependenciesRequest, executor?: CommandExecutor): Promise<WarmDependenciesResult>;
@@ -41,3 +44,9 @@ export declare function resolveNpmLaunch(args: readonly string[], options?: NpmL
41
44
  command: string;
42
45
  args: readonly string[];
43
46
  };
47
+ export declare function resolvePnpmLaunch(args: readonly string[]): {
48
+ command: string;
49
+ args: readonly string[];
50
+ };
51
+ export declare function formatCommandFailureOutput(stdout: string, stderr: string): string;
52
+ export declare function unattendedPackageManagerEnvironment(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -23,6 +23,7 @@ const NATIVE_MODULE_HINTS = [
23
23
  "better-sqlite3",
24
24
  "@swc/core"
25
25
  ];
26
+ const MAX_COMMAND_FAILURE_OUTPUT_LENGTH = 8_000;
26
27
  export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
27
28
  const entries = new Map();
28
29
  await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
@@ -50,10 +51,39 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
50
51
  const linked = [];
51
52
  const skipped = [];
52
53
  const fallbacks = [];
54
+ const restoredDotNetRoots = new Set();
55
+ const installedNodeRoots = new Set();
53
56
  for (const entry of warmPaths) {
54
57
  const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
55
58
  const targetPath = path.join(worktreePath, entry.relativePath);
56
59
  assertWithinRoot(allowedRoot, targetPath, "Warm target");
60
+ if (entry.ecosystem === "node" && path.basename(entry.relativePath) === "node_modules") {
61
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
62
+ const installPackageRoot = await resolveNodeInstallPackageRoot(worktreePath, entry.packageRoot);
63
+ const packageRoot = path.resolve(worktreePath, installPackageRoot === "." ? "" : installPackageRoot);
64
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
65
+ if (installedNodeRoots.has(packageKey)) {
66
+ skipped.push(entry.relativePath);
67
+ continue;
68
+ }
69
+ installedNodeRoots.add(packageKey);
70
+ const install = await runInstallFallback({ ...entry, packageRoot: installPackageRoot }, worktreePath, "Managed worktrees require isolated Node dependencies.", executor);
71
+ fallbacks.push(install);
72
+ continue;
73
+ }
74
+ if (entry.ecosystem === "dotnet") {
75
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
76
+ const packageRoot = path.resolve(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
77
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
78
+ if (restoredDotNetRoots.has(packageKey)) {
79
+ skipped.push(entry.relativePath);
80
+ continue;
81
+ }
82
+ restoredDotNetRoots.add(packageKey);
83
+ const install = await runInstallFallback(entry, worktreePath, "Managed worktrees require isolated .NET build outputs.", executor);
84
+ fallbacks.push(install);
85
+ continue;
86
+ }
57
87
  const sourceExists = await directoryExists(sourcePath);
58
88
  const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
59
89
  if (fallbackReason) {
@@ -70,6 +100,16 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
70
100
  }
71
101
  return { linked, skipped, fallbacks };
72
102
  }
103
+ async function removeLegacyDirectoryLink(targetPath, allowedRoot) {
104
+ const resolvedTarget = path.resolve(targetPath);
105
+ const resolvedRoot = path.resolve(allowedRoot);
106
+ assertWithinRoot(resolvedRoot, resolvedTarget, "Legacy warm target");
107
+ const stat = await fs.lstat(resolvedTarget).catch(() => null);
108
+ if (!stat?.isSymbolicLink()) {
109
+ return;
110
+ }
111
+ await fs.rm(resolvedTarget, { force: true });
112
+ }
73
113
  export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
74
114
  const resolvedSource = path.resolve(sourcePath);
75
115
  const resolvedTarget = path.resolve(targetPath);
@@ -81,7 +121,10 @@ export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
81
121
  if (!stat?.isDirectory()) {
82
122
  throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
83
123
  }
84
- await fs.rm(resolvedTarget, { recursive: true, force: true });
124
+ const existingTarget = await fs.lstat(resolvedTarget).catch(() => null);
125
+ await fs.rm(resolvedTarget, existingTarget?.isSymbolicLink()
126
+ ? { force: true }
127
+ : { recursive: true, force: true });
85
128
  await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
86
129
  if (process.platform === "win32") {
87
130
  await fs.symlink(resolvedSource, resolvedTarget, "junction");
@@ -185,10 +228,17 @@ export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRoo
185
228
  async function runInstallFallback(entry, worktreePath, reason, executor) {
186
229
  const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
187
230
  if (entry.ecosystem === "node") {
231
+ const pnpmLockPath = path.join(cwd, "pnpm-lock.yaml");
232
+ if (await pathExists(pnpmLockPath)) {
233
+ const args = ["install", "--frozen-lockfile"];
234
+ const launch = resolvePnpmLaunch(args);
235
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
236
+ return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `pnpm ${args.join(" ")}` };
237
+ }
188
238
  const lockPath = path.join(cwd, "package-lock.json");
189
239
  const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
190
240
  const launch = resolveNpmLaunch(args);
191
- await executor.run(cwd, launch.command, launch.args);
241
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
192
242
  return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
193
243
  }
194
244
  if (entry.ecosystem === "python") {
@@ -219,6 +269,23 @@ async function runInstallFallback(entry, worktreePath, reason, executor) {
219
269
  command: "dotnet restore"
220
270
  };
221
271
  }
272
+ async function resolveNodeInstallPackageRoot(worktreePath, packageRoot) {
273
+ const resolvedWorktree = path.resolve(worktreePath);
274
+ let current = path.resolve(resolvedWorktree, packageRoot === "." ? "" : packageRoot);
275
+ assertWithinRoot(resolvedWorktree, current, "Node package root");
276
+ while (isSubPath(resolvedWorktree, current)) {
277
+ if (await pathExists(path.join(current, "pnpm-lock.yaml"))
278
+ || await pathExists(path.join(current, "pnpm-workspace.yaml"))
279
+ || await pathExists(path.join(current, "package-lock.json"))) {
280
+ const relative = normalizeRelativePath(path.relative(resolvedWorktree, current));
281
+ return relative || ".";
282
+ }
283
+ if (current === resolvedWorktree)
284
+ break;
285
+ current = path.dirname(current);
286
+ }
287
+ return packageRoot;
288
+ }
222
289
  export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
223
290
  return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
224
291
  }
@@ -310,6 +377,26 @@ export function resolveNpmLaunch(args, options = {}) {
310
377
  }
311
378
  return { command: "npm", args };
312
379
  }
380
+ export function resolvePnpmLaunch(args) {
381
+ if (process.platform === "win32") {
382
+ return buildWindowsCmdLaunch("pnpm.cmd", args, process.env.ComSpec);
383
+ }
384
+ return { command: "pnpm", args };
385
+ }
386
+ export function formatCommandFailureOutput(stdout, stderr) {
387
+ const sections = [
388
+ stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
389
+ stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
390
+ ].filter((section) => section !== null);
391
+ const output = sections.join("\n");
392
+ if (output.length <= MAX_COMMAND_FAILURE_OUTPUT_LENGTH) {
393
+ return output;
394
+ }
395
+ return `[last ${MAX_COMMAND_FAILURE_OUTPUT_LENGTH} characters]\n${output.slice(-MAX_COMMAND_FAILURE_OUTPUT_LENGTH)}`;
396
+ }
397
+ export function unattendedPackageManagerEnvironment(env = process.env) {
398
+ return { ...env, CI: "true" };
399
+ }
313
400
  function tryResolveNpmCandidate(candidate) {
314
401
  try {
315
402
  if (!existsSync(candidate)) {
@@ -331,10 +418,12 @@ function defaultNpmCliCandidates(execPath, homeDir) {
331
418
  return candidates;
332
419
  }
333
420
  const defaultCommandExecutor = {
334
- async run(cwd, command, args) {
335
- const result = await defaultProcessRunner.run({ command, cwd, args });
421
+ async run(cwd, command, args, options) {
422
+ const result = await defaultProcessRunner.run({ command, cwd, args, env: options?.env });
336
423
  if (result.exitCode !== 0) {
337
- throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr.trim()}`);
424
+ const output = formatCommandFailureOutput(result.stdout, result.stderr);
425
+ const details = output.length > 0 ? `\n${output}` : " no output was captured";
426
+ throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}) in ${cwd}:${details}`);
338
427
  }
339
428
  }
340
429
  };
@@ -6,6 +6,7 @@ export declare class GitWorktreeService implements WorktreeService {
6
6
  private readonly coordinator;
7
7
  constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
8
8
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
9
+ private trackedForgePaths;
9
10
  private repoCreationCoordinationPath;
10
11
  release(handle: WorktreeHandle): Promise<void>;
11
12
  private resolveRepoPath;
@@ -59,7 +59,7 @@ export class GitWorktreeService {
59
59
  const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
60
60
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
61
61
  if (artifacts.length > 0) {
62
- await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
62
+ await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
63
63
  }
64
64
  const dependencyWarm = await warmWorktreeDependencies({
65
65
  baseCheckoutPath: repoPath,
@@ -78,6 +78,10 @@ export class GitWorktreeService {
78
78
  hydratedPriorPhaseArtifactCount
79
79
  };
80
80
  }
81
+ async trackedForgePaths(worktreePath) {
82
+ const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
83
+ return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
84
+ }
81
85
  repoCreationCoordinationPath(repo, allowedRoot) {
82
86
  if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
83
87
  return path.resolve(repo.serverLocalPath);
@@ -191,6 +195,13 @@ export class GitWorktreeService {
191
195
  return isSubPath(allowedRoot, resolved) ? resolved : null;
192
196
  }
193
197
  async resolveBaseStartPoint(repoPath, baseBranch) {
198
+ if (baseBranch === "HEAD") {
199
+ const head = (await this.git.run(repoPath, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
200
+ if (!/^[0-9a-f]{40,64}$/iu.test(head)) {
201
+ throw new Error("The repository checkout does not have a valid committed HEAD revision.");
202
+ }
203
+ return head;
204
+ }
194
205
  const localRef = `refs/heads/${baseBranch}`;
195
206
  const remoteRef = `refs/remotes/origin/${baseBranch}`;
196
207
  const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.3-dev.ba7d36726eda",
3
+ "version": "0.1.3-dev.bca7d0ccee53",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",