@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.3-dev.fbe15023cdfe

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 (150) hide show
  1. package/README.md +78 -0
  2. package/dist/bridge/bridge.base.d.ts +6 -1
  3. package/dist/bridge/bridge.service.d.ts +60 -3
  4. package/dist/bridge/bridge.service.js +970 -24
  5. package/dist/bridge/bridge.types.d.ts +172 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.d.ts +2 -0
  14. package/dist/cli/cli.help.js +217 -0
  15. package/dist/cli/cli.service.d.ts +42 -2
  16. package/dist/cli/cli.service.js +356 -205
  17. package/dist/cli/local-config.d.ts +6 -0
  18. package/dist/cli/local-config.js +47 -3
  19. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  20. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  22. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  23. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  24. package/dist/runtime/attempt-process-supervisor.js +350 -0
  25. package/dist/runtime/generated-output-reset.d.ts +10 -0
  26. package/dist/runtime/generated-output-reset.js +103 -0
  27. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  28. package/dist/runtime/host-listener-inspector.js +324 -0
  29. package/dist/runtime/host-port-broker.d.ts +76 -0
  30. package/dist/runtime/host-port-broker.js +333 -0
  31. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  32. package/dist/runtime/host-runtime-contract.js +700 -0
  33. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  34. package/dist/runtime/host-runtime-lease.js +1460 -0
  35. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  36. package/dist/runtime/host-runtime-promotion.js +159 -0
  37. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  38. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  40. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  41. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  42. package/dist/runtime/runtime-isolation-lease.js +158 -0
  43. package/dist/sandbox/command-runner.d.ts +4 -0
  44. package/dist/sandbox/command-runner.js +47 -0
  45. package/dist/sandbox/contract.d.ts +5 -0
  46. package/dist/sandbox/contract.js +276 -0
  47. package/dist/sandbox/event-budget.d.ts +11 -0
  48. package/dist/sandbox/event-budget.js +27 -0
  49. package/dist/sandbox/image-policy.d.ts +35 -0
  50. package/dist/sandbox/image-policy.js +247 -0
  51. package/dist/sandbox/index.d.ts +5 -0
  52. package/dist/sandbox/index.js +5 -0
  53. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  54. package/dist/sandbox/lease-watchdog.js +67 -0
  55. package/dist/sandbox/preflight.d.ts +54 -0
  56. package/dist/sandbox/preflight.js +335 -0
  57. package/dist/sandbox/process.d.ts +20 -0
  58. package/dist/sandbox/process.js +209 -0
  59. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  60. package/dist/sandbox/provider-auth/codex.js +212 -0
  61. package/dist/sandbox/repository-lock.d.ts +5 -0
  62. package/dist/sandbox/repository-lock.js +119 -0
  63. package/dist/sandbox/runtime.d.ts +297 -0
  64. package/dist/sandbox/runtime.js +2338 -0
  65. package/dist/sandbox/sandbox.types.d.ts +86 -0
  66. package/dist/sandbox/sandbox.types.js +1 -0
  67. package/dist/sandbox/smoke.d.ts +76 -0
  68. package/dist/sandbox/smoke.js +380 -0
  69. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  70. package/dist/sandbox/worker-supervisor.js +36 -0
  71. package/dist/sandbox/worker.d.ts +1 -0
  72. package/dist/sandbox/worker.js +225 -0
  73. package/dist/sandbox/workspace.service.d.ts +13 -0
  74. package/dist/sandbox/workspace.service.js +72 -0
  75. package/dist/session/managed-attempt-tools.d.ts +12 -0
  76. package/dist/session/managed-attempt-tools.js +561 -0
  77. package/dist/session/phase-completion.artifacts.d.ts +10 -0
  78. package/dist/session/phase-completion.artifacts.js +627 -0
  79. package/dist/session/phase-completion.d.ts +43 -42
  80. package/dist/session/phase-completion.helpers.d.ts +112 -0
  81. package/dist/session/phase-completion.helpers.js +684 -0
  82. package/dist/session/phase-completion.js +114 -3007
  83. package/dist/session/phase-completion.prompts.d.ts +14 -0
  84. package/dist/session/phase-completion.prompts.js +234 -0
  85. package/dist/session/phase-completion.runtime.d.ts +47 -0
  86. package/dist/session/phase-completion.runtime.js +731 -0
  87. package/dist/session/phase-completion.schemas.d.ts +3 -0
  88. package/dist/session/phase-completion.schemas.js +812 -0
  89. package/dist/session/phase-completion.validation.d.ts +37 -0
  90. package/dist/session/phase-completion.validation.js +828 -0
  91. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  92. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  93. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +93 -0
  94. package/dist/session/provider-cli/provider-cli.artifacts.js +778 -0
  95. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  96. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  97. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  98. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  99. package/dist/session/provider-cli/provider-cli.deterministic.js +375 -0
  100. package/dist/session/provider-cli/provider-cli.process.d.ts +48 -0
  101. package/dist/session/provider-cli/provider-cli.process.js +474 -0
  102. package/dist/session/provider-cli/provider-cli.prompts.d.ts +47 -0
  103. package/dist/session/provider-cli/provider-cli.prompts.js +595 -0
  104. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  105. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  106. package/dist/session/provider-cli/provider-cli.runtime.d.ts +9 -0
  107. package/dist/session/provider-cli/provider-cli.runtime.js +41 -0
  108. package/dist/session/provider-cli/provider-cli.service.d.ts +22 -53
  109. package/dist/session/provider-cli/provider-cli.service.js +587 -2530
  110. package/dist/session/provider-cli/provider-cli.types.d.ts +6 -0
  111. package/dist/session/provider-cli/provider-output-state.d.ts +22 -0
  112. package/dist/session/provider-cli/provider-output-state.js +473 -0
  113. package/dist/session/session-redaction.d.ts +3 -0
  114. package/dist/session/session-redaction.js +23 -0
  115. package/dist/session/session.base.d.ts +6 -1
  116. package/dist/session/session.service.d.ts +8 -2
  117. package/dist/session/session.service.js +463 -58
  118. package/dist/session/session.types.d.ts +177 -1
  119. package/dist/session/session.types.js +352 -3
  120. package/dist/shared/path/path.js +3 -1
  121. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  122. package/dist/shared/process/host-process-recovery.js +78 -0
  123. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  124. package/dist/shared/process/host-process-supervisor.js +282 -0
  125. package/dist/shared/process/process.d.ts +1 -0
  126. package/dist/shared/process/process.js +1 -0
  127. package/dist/shared/process/windows-launch.d.ts +8 -0
  128. package/dist/shared/process/windows-launch.js +41 -0
  129. package/dist/shared/provider-capabilities.d.ts +1 -0
  130. package/dist/shared/provider-capabilities.js +3 -0
  131. package/dist/shared/redaction/redaction.js +10 -3
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/worktree/artifact-injector.d.ts +3 -2
  135. package/dist/worktree/artifact-injector.js +82 -10
  136. package/dist/worktree/dependency-warmer.d.ts +21 -1
  137. package/dist/worktree/dependency-warmer.js +138 -5
  138. package/dist/worktree/index.d.ts +1 -0
  139. package/dist/worktree/index.js +1 -0
  140. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  141. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  142. package/dist/worktree/worktree-paths.d.ts +4 -0
  143. package/dist/worktree/worktree-paths.js +30 -0
  144. package/dist/worktree/worktree.base.d.ts +3 -0
  145. package/dist/worktree/worktree.service.d.ts +9 -3
  146. package/dist/worktree/worktree.service.js +100 -52
  147. package/dist/worktree/worktree.types.d.ts +3 -1
  148. package/package.json +6 -3
  149. package/runtime/windows-job-supervisor.ps1 +356 -0
  150. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,350 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ const terminalChildStatuses = new Set(["exited", "failed"]);
5
+ const defaultMetadataReadyTimeoutMs = process.platform === "win32" ? 60_000 : 10_000;
6
+ /**
7
+ * Owns the independently supervised process trees that belong to one host
8
+ * dispatch attempt. Each named child gets a distinct Windows Job Object or
9
+ * Unix process group through the existing process runner, while this
10
+ * coordinator supplies shared fencing, cancellation, and a durable ledger.
11
+ */
12
+ export class AttemptProcessSupervisor {
13
+ runner;
14
+ ledgerPath;
15
+ identity;
16
+ metadataReadyTimeoutMs;
17
+ records = [];
18
+ currentByName = new Map();
19
+ attemptController = new AbortController();
20
+ status = "active";
21
+ persistTail = Promise.resolve();
22
+ persistenceFailure;
23
+ stopOperation = null;
24
+ constructor(identity, runner, options = {}) {
25
+ this.runner = runner;
26
+ this.identity = validateIdentity(identity);
27
+ this.metadataReadyTimeoutMs = boundedInteger(options.metadataReadyTimeoutMs ?? defaultMetadataReadyTimeoutMs, 100, 120_000, "metadataReadyTimeoutMs");
28
+ this.ledgerPath = path.join(this.identity.logDirectory, "attempt-process-supervisor.json");
29
+ if (options.signal?.aborted) {
30
+ this.status = "stopping";
31
+ this.attemptController.abort(options.signal.reason);
32
+ }
33
+ else {
34
+ options.signal?.addEventListener("abort", () => {
35
+ void this.stopAll(this.identity.fencingToken, options.signal?.reason).catch(error => {
36
+ this.persistenceFailure ??= error;
37
+ });
38
+ }, { once: true });
39
+ }
40
+ }
41
+ async startChild(name, request, handlers = {}, signal) {
42
+ assertChildName(name);
43
+ if (this.status !== "active" || this.attemptController.signal.aborted) {
44
+ throw new Error("attempt_process_supervisor_closed: the attempt no longer accepts child processes.");
45
+ }
46
+ if (signal?.aborted)
47
+ throw cancellationError(signal);
48
+ const existing = this.currentByName.get(name);
49
+ if (existing && !terminalChildStatuses.has(existing.status)) {
50
+ throw new Error(`attempt_process_supervisor_conflict: child '${name}' is already active.`);
51
+ }
52
+ const generation = (existing?.generation ?? 0) + 1;
53
+ const childId = `${name}:${generation}`;
54
+ const processSupervisorId = childProcessSupervisorId(this.identity.processSupervisorId, childId);
55
+ const metadataPath = path.join(this.identity.logDirectory, `process-supervisor.${name}.${generation}.json`);
56
+ const controller = new AbortController();
57
+ const combinedSignal = signal
58
+ ? AbortSignal.any([this.attemptController.signal, controller.signal, signal])
59
+ : AbortSignal.any([this.attemptController.signal, controller.signal]);
60
+ const childRequest = {
61
+ ...request,
62
+ env: {
63
+ ...request.env,
64
+ FORGE_RUNTIME_MODE: "host",
65
+ FORGE_RESOURCE_LEASE_ID: this.identity.executionLeaseId,
66
+ FORGE_EXECUTION_LEASE_ID: this.identity.executionLeaseId,
67
+ FORGE_DISPATCH_ATTEMPT_ID: this.identity.dispatchAttemptId,
68
+ FORGE_FENCING_TOKEN: this.identity.fencingToken,
69
+ FORGE_LOG_DIR: this.identity.logDirectory,
70
+ FORGE_PROCESS_SUPERVISOR_ID: processSupervisorId,
71
+ FORGE_PROCESS_SUPERVISOR_METADATA_PATH: metadataPath,
72
+ FORGE_PROCESS_CHILD_ID: childId,
73
+ FORGE_PROCESS_CHILD_NAME: name,
74
+ FORGE_PROCESS_CHILD_GENERATION: String(generation),
75
+ },
76
+ };
77
+ const startedAt = new Date().toISOString();
78
+ const record = {
79
+ name,
80
+ generation,
81
+ childId,
82
+ processSupervisorId,
83
+ metadataPath,
84
+ controller,
85
+ startedAt,
86
+ status: "starting",
87
+ result: Promise.resolve({ exitCode: 1, stdout: "", stderr: "not started" }),
88
+ };
89
+ this.records.push(record);
90
+ this.currentByName.set(name, record);
91
+ const result = this.runner.run(childRequest, handlers, combinedSignal);
92
+ record.result = result;
93
+ void result.then(value => this.settleChild(record, "exited", value.exitCode), error => this.settleChild(record, "failed", undefined, error));
94
+ try {
95
+ await this.persist();
96
+ }
97
+ catch (error) {
98
+ controller.abort("Attempt process ledger could not be persisted.");
99
+ await result.catch(() => undefined);
100
+ throw error;
101
+ }
102
+ const ready = this.observeChildReady(record);
103
+ void ready.catch(() => undefined);
104
+ return {
105
+ name,
106
+ generation,
107
+ childId,
108
+ processSupervisorId,
109
+ metadataPath,
110
+ ready,
111
+ result,
112
+ stop: async (fencingToken) => await this.stopChild(name, generation, fencingToken),
113
+ };
114
+ }
115
+ /**
116
+ * Starts a child and proves that the OS process tree is supervised before
117
+ * returning it to the caller. A launch that settles before producing its
118
+ * provider-ready supervision record is safe to retry: the raw runner only
119
+ * persists that record after the actual provider child has started, before
120
+ * Forge announces the provider as active.
121
+ */
122
+ async startChildReady(name, request, handlers = {}, signal, options = {}) {
123
+ const maxStartAttempts = boundedInteger(options.maxStartAttempts ?? 1, 1, 5, "maxStartAttempts");
124
+ const retryDelayMs = boundedInteger(options.retryDelayMs ?? 100, 0, 5_000, "retryDelayMs");
125
+ let lastError;
126
+ for (let attempt = 1; attempt <= maxStartAttempts; attempt += 1) {
127
+ const child = await this.startChild(name, request, handlers, signal);
128
+ try {
129
+ await child.ready;
130
+ return child;
131
+ }
132
+ catch (error) {
133
+ lastError = error;
134
+ await child.result.catch(() => undefined);
135
+ if (!isRetryableStartFailure(error) || attempt === maxStartAttempts)
136
+ throw error;
137
+ if (signal?.aborted)
138
+ throw cancellationError(signal);
139
+ if (retryDelayMs > 0)
140
+ await wait(retryDelayMs * attempt);
141
+ }
142
+ }
143
+ throw lastError;
144
+ }
145
+ async stopChild(name, generation, fencingToken, reason = "Forge stopped the supervised child.") {
146
+ this.assertFence(fencingToken);
147
+ const record = this.currentByName.get(name);
148
+ if (!record || record.generation !== generation) {
149
+ throw new Error(`attempt_process_supervisor_stale: child '${name}' generation ${generation} is no longer current.`);
150
+ }
151
+ if (!terminalChildStatuses.has(record.status)) {
152
+ record.status = "stopping";
153
+ record.controller.abort(reason);
154
+ await this.persist();
155
+ }
156
+ return await record.result;
157
+ }
158
+ async stopAll(fencingToken, reason = "Forge stopped the supervised attempt.") {
159
+ this.assertFence(fencingToken);
160
+ // The durable terminal ledger is part of the stop operation's contract.
161
+ // Join an in-flight stop before inspecting terminal state so lease cleanup
162
+ // cannot remove the log directory underneath its final persistence.
163
+ if (this.stopOperation)
164
+ return await this.stopOperation;
165
+ if (this.status === "stopped")
166
+ return;
167
+ const operation = this.performStopAll(reason);
168
+ this.stopOperation = operation;
169
+ void operation.catch(() => undefined).finally(() => {
170
+ if (this.stopOperation === operation && this.status !== "stopped")
171
+ this.stopOperation = null;
172
+ });
173
+ return await this.stopOperation;
174
+ }
175
+ async performStopAll(reason) {
176
+ this.status = "stopping";
177
+ this.attemptController.abort(reason);
178
+ const active = [...this.currentByName.values()].filter(record => !terminalChildStatuses.has(record.status));
179
+ for (const record of active)
180
+ record.status = "stopping";
181
+ await this.persist();
182
+ const results = await Promise.allSettled(active.map(record => record.result));
183
+ const rejected = results.filter((result) => result.status === "rejected");
184
+ const terminalStatus = rejected.length > 0 || this.persistenceFailure !== undefined ? "degraded" : "stopped";
185
+ await this.persist({
186
+ ...this.snapshot(),
187
+ status: terminalStatus,
188
+ updatedAt: new Date().toISOString(),
189
+ });
190
+ this.status = terminalStatus;
191
+ if (rejected.length > 0) {
192
+ throw new AggregateError(rejected.map(result => result.reason), "attempt_process_supervisor_cleanup_degraded");
193
+ }
194
+ if (this.persistenceFailure !== undefined)
195
+ throw this.persistenceFailure;
196
+ }
197
+ snapshot() {
198
+ return {
199
+ schemaVersion: 1,
200
+ attemptSupervisorId: this.identity.processSupervisorId,
201
+ dispatchAttemptId: this.identity.dispatchAttemptId,
202
+ executionLeaseId: this.identity.executionLeaseId,
203
+ status: this.status,
204
+ updatedAt: new Date().toISOString(),
205
+ children: this.records.map(record => ({
206
+ name: record.name,
207
+ generation: record.generation,
208
+ childId: record.childId,
209
+ processSupervisorId: record.processSupervisorId,
210
+ metadataPath: record.metadataPath,
211
+ status: record.status,
212
+ startedAt: record.startedAt,
213
+ ...(record.settledAt ? { settledAt: record.settledAt } : {}),
214
+ ...(record.exitCode === undefined ? {} : { exitCode: record.exitCode }),
215
+ ...(record.failure ? { failure: record.failure } : {}),
216
+ })),
217
+ };
218
+ }
219
+ async observeChildReady(record) {
220
+ const deadline = Date.now() + this.metadataReadyTimeoutMs;
221
+ while (Date.now() <= deadline) {
222
+ if (await fileExists(record.metadataPath)) {
223
+ if (record.status === "starting") {
224
+ record.status = "running";
225
+ await this.persist();
226
+ }
227
+ return;
228
+ }
229
+ if (terminalChildStatuses.has(record.status)) {
230
+ const detail = await childStartFailureDetail(record);
231
+ throw new Error(`attempt_process_supervisor_start_failed: child '${record.childId}' exited before supervision metadata was produced${detail ? `: ${detail}` : "."}`);
232
+ }
233
+ await wait(25);
234
+ }
235
+ record.controller.abort("Supervised child metadata was not produced before the readiness deadline.");
236
+ const detail = await childStartFailureDetail(record);
237
+ throw new Error(`attempt_process_supervisor_start_timeout: child '${record.childId}' did not become supervised${detail ? `: ${detail}` : "."}`);
238
+ }
239
+ async settleChild(record, status, exitCode, failure) {
240
+ record.status = status;
241
+ record.settledAt = new Date().toISOString();
242
+ record.exitCode = exitCode;
243
+ record.failure = failure === undefined ? undefined : boundedFailure(failure);
244
+ await this.persist().catch(error => {
245
+ this.persistenceFailure ??= error;
246
+ this.status = "degraded";
247
+ });
248
+ }
249
+ assertFence(fencingToken) {
250
+ if (fencingToken !== this.identity.fencingToken) {
251
+ throw new Error("attempt_process_supervisor_fenced: fencing token does not own this attempt.");
252
+ }
253
+ }
254
+ persist(snapshot = this.snapshot()) {
255
+ const operation = this.persistTail.then(async () => {
256
+ await ensurePrivateDirectory(this.identity.logDirectory);
257
+ const temporary = `${this.ledgerPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
258
+ try {
259
+ const handle = await fs.open(temporary, "wx", 0o600);
260
+ try {
261
+ await handle.writeFile(`${JSON.stringify(snapshot)}\n`);
262
+ await handle.sync();
263
+ }
264
+ finally {
265
+ await handle.close();
266
+ }
267
+ await fs.rename(temporary, this.ledgerPath);
268
+ }
269
+ catch (error) {
270
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
271
+ throw error;
272
+ }
273
+ });
274
+ this.persistTail = operation.catch(() => undefined);
275
+ return operation;
276
+ }
277
+ }
278
+ function validateIdentity(identity) {
279
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(identity.processSupervisorId)) {
280
+ throw new Error("attempt_process_supervisor_invalid: process supervisor id is invalid.");
281
+ }
282
+ assertUuid(identity.dispatchAttemptId, "dispatch attempt");
283
+ assertUuid(identity.executionLeaseId, "execution lease");
284
+ assertUuid(identity.fencingToken, "fencing token");
285
+ if (!path.isAbsolute(identity.logDirectory)) {
286
+ throw new Error("attempt_process_supervisor_invalid: log directory must be absolute.");
287
+ }
288
+ return { ...identity, logDirectory: path.resolve(identity.logDirectory) };
289
+ }
290
+ function assertChildName(name) {
291
+ if (!/^[a-z][a-z0-9_-]{0,31}$/u.test(name)) {
292
+ throw new Error("attempt_process_supervisor_invalid: child name must be a safe lowercase identifier.");
293
+ }
294
+ }
295
+ function childProcessSupervisorId(attemptSupervisorId, childId) {
296
+ const digest = createHash("sha256")
297
+ .update(JSON.stringify([attemptSupervisorId, childId]))
298
+ .digest("hex")
299
+ .slice(0, 32);
300
+ return `host-${digest}`;
301
+ }
302
+ function assertUuid(value, label) {
303
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)) {
304
+ throw new Error(`attempt_process_supervisor_invalid: ${label} id must be a UUID.`);
305
+ }
306
+ }
307
+ function boundedInteger(value, minimum, maximum, name) {
308
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
309
+ throw new Error(`attempt_process_supervisor_invalid: ${name} must be between ${minimum} and ${maximum}.`);
310
+ }
311
+ return value;
312
+ }
313
+ function boundedFailure(error) {
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ return message.replaceAll(/[\r\n\t]+/gu, " ").slice(0, 512);
316
+ }
317
+ async function childStartFailureDetail(record) {
318
+ try {
319
+ const result = await record.result;
320
+ const stderr = boundedDiagnostic(result.stderr);
321
+ return stderr
322
+ ? `exit ${result.exitCode}; stderr: ${stderr}`
323
+ : `exit ${result.exitCode}`;
324
+ }
325
+ catch (error) {
326
+ return boundedFailure(error);
327
+ }
328
+ }
329
+ function boundedDiagnostic(value) {
330
+ return value.replaceAll(/[\r\n\t]+/gu, " ").trim().slice(0, 512);
331
+ }
332
+ function isRetryableStartFailure(error) {
333
+ return error instanceof Error
334
+ && (error.message.startsWith("attempt_process_supervisor_start_failed:")
335
+ || error.message.startsWith("attempt_process_supervisor_start_timeout:"));
336
+ }
337
+ function cancellationError(signal) {
338
+ return new Error(String(signal.reason ?? "Supervised child launch was cancelled."));
339
+ }
340
+ async function ensurePrivateDirectory(directory) {
341
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
342
+ if (process.platform !== "win32")
343
+ await fs.chmod(directory, 0o700);
344
+ }
345
+ async function fileExists(target) {
346
+ return await fs.stat(target).then(() => true, () => false);
347
+ }
348
+ function wait(milliseconds) {
349
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
350
+ }
@@ -0,0 +1,10 @@
1
+ export interface GeneratedOutputResetResult {
2
+ readonly quarantineDirectory: string;
3
+ readonly quarantinedPaths: readonly string[];
4
+ }
5
+ /**
6
+ * Moves only known, untracked test-output directories out of a worktree before
7
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
8
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
9
+ */
10
+ export declare function resetGeneratedOutputs(worktreePath: string, testOutputDirectory: string): Promise<GeneratedOutputResetResult>;
@@ -0,0 +1,103 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const generatedDirectoryNames = new Set(["TestResults", "test-results", "playwright-report"]);
8
+ const skippedDirectoryNames = new Set([
9
+ ".git",
10
+ ".forge",
11
+ ".idea",
12
+ ".next",
13
+ ".pnpm",
14
+ ".vs",
15
+ ".vscode",
16
+ "bin",
17
+ "build",
18
+ "coverage",
19
+ "dist",
20
+ "node_modules",
21
+ "obj",
22
+ "target",
23
+ "vendor",
24
+ ]);
25
+ /**
26
+ * Moves only known, untracked test-output directories out of a worktree before
27
+ * a managed attempt starts. The quarantine lives in the attempt-owned output
28
+ * directory, so cleanup is recoverable and cannot cross the attempt boundary.
29
+ */
30
+ export async function resetGeneratedOutputs(worktreePath, testOutputDirectory) {
31
+ const worktreeRoot = await fs.realpath(path.resolve(worktreePath));
32
+ const outputRoot = await fs.realpath(path.resolve(testOutputDirectory));
33
+ if (isContainedPath(worktreeRoot, outputRoot)) {
34
+ throw new Error("generated_output_reset_invalid: attempt output must be outside the source worktree.");
35
+ }
36
+ const candidates = await findGeneratedDirectories(worktreeRoot);
37
+ const quarantineDirectory = path.join(outputRoot, "preflight-reset");
38
+ const quarantinedPaths = [];
39
+ for (const candidate of candidates) {
40
+ const relativePath = path.relative(worktreeRoot, candidate);
41
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
42
+ throw new Error("generated_output_reset_invalid: generated output escaped the source worktree.");
43
+ }
44
+ await assertUntrackedGeneratedDirectory(worktreeRoot, relativePath);
45
+ await fs.mkdir(quarantineDirectory, { recursive: true, mode: 0o700 });
46
+ const digest = createHash("sha256").update(relativePath).digest("hex").slice(0, 12);
47
+ const destination = path.join(quarantineDirectory, `${digest}-${randomUUID()}-${path.basename(candidate)}`);
48
+ try {
49
+ await fs.rename(candidate, destination);
50
+ }
51
+ catch (error) {
52
+ const code = error.code ?? "unknown";
53
+ throw new Error(`generated_output_reset_failed: could not quarantine ${relativePath} (${code}).`, { cause: error });
54
+ }
55
+ quarantinedPaths.push(relativePath.split(path.sep).join("/"));
56
+ }
57
+ if (quarantinedPaths.length > 0) {
58
+ await fs.writeFile(path.join(quarantineDirectory, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, worktreePath: worktreeRoot, quarantinedPaths }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
59
+ }
60
+ return { quarantineDirectory, quarantinedPaths };
61
+ }
62
+ async function findGeneratedDirectories(root) {
63
+ const candidates = [];
64
+ const pending = [root];
65
+ while (pending.length > 0) {
66
+ const current = pending.pop();
67
+ const entries = await fs.readdir(current, { withFileTypes: true });
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory() || entry.isSymbolicLink())
70
+ continue;
71
+ if (skippedDirectoryNames.has(entry.name))
72
+ continue;
73
+ const candidate = path.join(current, entry.name);
74
+ if (generatedDirectoryNames.has(entry.name)) {
75
+ const realCandidate = await fs.realpath(candidate);
76
+ if (!isContainedPath(root, realCandidate)) {
77
+ throw new Error("generated_output_reset_invalid: generated output resolves outside the source worktree.");
78
+ }
79
+ candidates.push(realCandidate);
80
+ continue;
81
+ }
82
+ pending.push(candidate);
83
+ }
84
+ }
85
+ return candidates.sort((left, right) => left.localeCompare(right));
86
+ }
87
+ async function assertUntrackedGeneratedDirectory(worktreeRoot, relativePath) {
88
+ const pathspec = relativePath.split(path.sep).join("/");
89
+ let stdout;
90
+ try {
91
+ ({ stdout } = await execFileAsync("git", ["-C", worktreeRoot, "ls-files", "-z", "--", pathspec], { encoding: "utf8", windowsHide: true, maxBuffer: 4 * 1024 * 1024 }));
92
+ }
93
+ catch (error) {
94
+ throw new Error(`generated_output_reset_unverified: git could not verify ${pathspec}.`, { cause: error });
95
+ }
96
+ if (stdout.length > 0) {
97
+ throw new Error(`generated_output_reset_unsafe: ${pathspec} contains tracked files and was not changed.`);
98
+ }
99
+ }
100
+ function isContainedPath(root, candidate) {
101
+ const relative = path.relative(root, candidate);
102
+ return relative.length > 0 && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
103
+ }
@@ -0,0 +1,44 @@
1
+ import type { AttemptProcessChildHandle } from "./attempt-process-supervisor.js";
2
+ import type { HostPortLeaseOwner, HostPortReservation } from "./host-port-broker.js";
3
+ import type { HostListenerOwnership } from "./node-host-runtime-adapter.js";
4
+ export interface HostInspectionCommandResult {
5
+ readonly exitCode: number;
6
+ readonly stdout: string;
7
+ readonly stderr: string;
8
+ readonly errorCode?: string;
9
+ }
10
+ export interface HostInspectionCommandRunner {
11
+ run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
12
+ }
13
+ /** Proves that a loopback listener belongs to one exact supervised child. */
14
+ export declare class HostListenerInspector {
15
+ private readonly commands;
16
+ private readonly platform;
17
+ constructor(commands?: HostInspectionCommandRunner, platform?: NodeJS.Platform);
18
+ inspect(child: AttemptProcessChildHandle, reservation: HostPortReservation, owner: HostPortLeaseOwner): Promise<HostListenerOwnership>;
19
+ /** Returns loopback TCP ports opened by one exact supervised child process tree. */
20
+ inspectOwnedLoopbackListeners(child: AttemptProcessChildHandle, owner: HostPortLeaseOwner): Promise<readonly number[]>;
21
+ private inspectWindows;
22
+ private inspectAllWindows;
23
+ private inspectUnix;
24
+ private inspectAllUnix;
25
+ }
26
+ export declare function parseWindowsLoopbackListeners(output: string): readonly {
27
+ pid: number;
28
+ port: number;
29
+ }[];
30
+ export declare function parseLsofLoopbackListeners(output: string): readonly {
31
+ pid: number;
32
+ port: number;
33
+ }[];
34
+ export declare function parseSsLoopbackListeners(output: string): readonly {
35
+ pid: number;
36
+ port: number;
37
+ }[];
38
+ export declare class ExecFileInspectionCommandRunner implements HostInspectionCommandRunner {
39
+ run(command: string, args: readonly string[]): Promise<HostInspectionCommandResult>;
40
+ }
41
+ export declare function parseWindowsListenerPids(output: string, port: number): readonly number[];
42
+ export declare function parseLsofPids(output: string): readonly number[];
43
+ export declare function parseSsPids(output: string, port: number): readonly number[];
44
+ export declare function parseProcessGroups(output: string): ReadonlyMap<number, number>;