@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

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 (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -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.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -0,0 +1,23 @@
1
+ import type { LoadedHostRuntimeContract } from "./host-runtime-contract.js";
2
+ export interface HostParallelPromotionRecord {
3
+ readonly schemaVersion: 1;
4
+ readonly repoId: string;
5
+ readonly runtimeContractFingerprint: string;
6
+ readonly runtimeContractSource?: "project" | "derived";
7
+ readonly observedAt: string;
8
+ readonly observedDispatchAttemptId: string;
9
+ readonly verifiedPortNames: readonly string[];
10
+ }
11
+ /** Persists only observed, contract-bound host parallel-safety evidence. */
12
+ export declare class HostParallelPromotionStore {
13
+ private readonly stateRoot;
14
+ constructor(stateRoot: string);
15
+ load(repoId: string): Promise<HostParallelPromotionRecord | null>;
16
+ list(): Promise<readonly HostParallelPromotionRecord[]>;
17
+ observe(repoId: string, runtimeContract: LoadedHostRuntimeContract, dispatchAttemptId: string, verifiedPortNames: readonly string[]): Promise<HostParallelPromotionRecord>;
18
+ invalidate(repoId: string): Promise<void>;
19
+ private recordPath;
20
+ }
21
+ export declare function hostRuntimeContractFingerprint(runtimeContract: LoadedHostRuntimeContract): string;
22
+ export declare function hostRuntimeParallelEligible(runtimeContract: LoadedHostRuntimeContract): boolean;
23
+ export declare function configuredLaunchPortNames(runtimeContract: LoadedHostRuntimeContract): readonly string[];
@@ -0,0 +1,159 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** Persists only observed, contract-bound host parallel-safety evidence. */
5
+ export class HostParallelPromotionStore {
6
+ stateRoot;
7
+ constructor(stateRoot) {
8
+ this.stateRoot = stateRoot;
9
+ }
10
+ async load(repoId) {
11
+ const normalizedRepoId = normalizeRepoId(repoId);
12
+ try {
13
+ const parsed = JSON.parse(await fs.readFile(this.recordPath(normalizedRepoId), "utf8"));
14
+ return validRecord(parsed, normalizedRepoId) ? parsed : null;
15
+ }
16
+ catch (error) {
17
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
18
+ return null;
19
+ throw error;
20
+ }
21
+ }
22
+ async list() {
23
+ const root = path.join(this.stateRoot, "runtime-promotion", "host");
24
+ let entries;
25
+ try {
26
+ entries = await fs.readdir(root);
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return [];
31
+ throw error;
32
+ }
33
+ const records = [];
34
+ for (const entry of entries.filter(value => /^[0-9a-f]{64}\.json$/u.test(value)).slice(0, 4096)) {
35
+ try {
36
+ const parsed = JSON.parse(await fs.readFile(path.join(root, entry), "utf8"));
37
+ const repoId = typeof parsed.repoId === "string" ? normalizeRepoId(parsed.repoId) : "";
38
+ if (repoId && validRecord(parsed, repoId))
39
+ records.push(parsed);
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof SyntaxError))
43
+ throw error;
44
+ }
45
+ }
46
+ return records.sort((left, right) => left.repoId.localeCompare(right.repoId));
47
+ }
48
+ async observe(repoId, runtimeContract, dispatchAttemptId, verifiedPortNames) {
49
+ if (!hostRuntimeParallelEligible(runtimeContract)) {
50
+ throw new Error("host_parallel_promotion_ineligible: the runtime contract is not eligible for parallel host execution.");
51
+ }
52
+ const requiredPorts = configuredLaunchPortNames(runtimeContract);
53
+ const observedPorts = [...new Set(verifiedPortNames)].sort((left, right) => left.localeCompare(right));
54
+ if (requiredPorts.length !== observedPorts.length
55
+ || requiredPorts.some((name, index) => name !== observedPorts[index])) {
56
+ throw new Error("host_parallel_promotion_unverified: every configured runtime listener must pass its assigned-port probe.");
57
+ }
58
+ const normalizedRepoId = normalizeRepoId(repoId);
59
+ const record = {
60
+ schemaVersion: 1,
61
+ repoId: normalizedRepoId,
62
+ runtimeContractFingerprint: hostRuntimeContractFingerprint(runtimeContract),
63
+ runtimeContractSource: runtimeContract.source === "project" ? "project" : "derived",
64
+ observedAt: new Date().toISOString(),
65
+ observedDispatchAttemptId: normalizedUuid(dispatchAttemptId, "dispatch attempt"),
66
+ verifiedPortNames: observedPorts,
67
+ };
68
+ const target = this.recordPath(normalizedRepoId);
69
+ const root = path.dirname(target);
70
+ await ensurePrivateDirectory(root);
71
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
72
+ try {
73
+ const handle = await fs.open(temporary, "wx", 0o600);
74
+ try {
75
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
76
+ await handle.sync();
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ await fs.rename(temporary, target);
82
+ return record;
83
+ }
84
+ catch (error) {
85
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
86
+ throw error;
87
+ }
88
+ }
89
+ async invalidate(repoId) {
90
+ await fs.rm(this.recordPath(normalizeRepoId(repoId)), { force: true });
91
+ }
92
+ recordPath(normalizedRepoId) {
93
+ const key = createHash("sha256").update(normalizedRepoId).digest("hex");
94
+ return path.join(this.stateRoot, "runtime-promotion", "host", `${key}.json`);
95
+ }
96
+ }
97
+ export function hostRuntimeContractFingerprint(runtimeContract) {
98
+ const canonical = JSON.stringify({
99
+ schemaVersion: runtimeContract.contract.schemaVersion,
100
+ runtime: runtimeContract.contract.runtime,
101
+ derivedCandidateFingerprint: runtimeContract.source === "derived"
102
+ ? runtimeContract.discovery?.candidateFingerprint ?? null
103
+ : null,
104
+ });
105
+ return `sha256:${createHash("sha256").update(canonical).digest("hex")}`;
106
+ }
107
+ export function hostRuntimeParallelEligible(runtimeContract) {
108
+ const runtime = runtimeContract.contract.runtime;
109
+ const launchedPorts = runtime.ports.filter(port => port.launch !== null);
110
+ return (runtimeContract.source === "project" || runtimeContract.source === "derived")
111
+ && runtime.hostConcurrency === "parallel"
112
+ && runtime.browserProfile === "isolated"
113
+ && runtime.singletonResources.length === 0
114
+ && launchedPorts.length > 0
115
+ && launchedPorts.length === runtime.ports.length
116
+ && launchedPorts.every(port => port.adapter !== null);
117
+ }
118
+ export function configuredLaunchPortNames(runtimeContract) {
119
+ return runtimeContract.contract.runtime.ports
120
+ .filter(port => port.launch !== null)
121
+ .map(port => port.name)
122
+ .sort((left, right) => left.localeCompare(right));
123
+ }
124
+ function validRecord(value, repoId) {
125
+ if (!value || typeof value !== "object" || Array.isArray(value))
126
+ return false;
127
+ const record = value;
128
+ return record.schemaVersion === 1
129
+ && record.repoId === repoId
130
+ && /^sha256:[0-9a-f]{64}$/u.test(record.runtimeContractFingerprint ?? "")
131
+ && Number.isFinite(Date.parse(record.observedAt ?? ""))
132
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(record.observedDispatchAttemptId ?? "")
133
+ && Array.isArray(record.verifiedPortNames)
134
+ && record.verifiedPortNames.length > 0
135
+ && record.verifiedPortNames.length <= 16
136
+ && record.verifiedPortNames.every(name => typeof name === "string" && /^[a-z][a-z0-9_-]{0,63}$/u.test(name));
137
+ }
138
+ function normalizeRepoId(repoId) {
139
+ const normalized = repoId.trim().toLowerCase();
140
+ if (!normalized || normalized.length > 512 || Array.from(normalized).some(character => {
141
+ const codePoint = character.codePointAt(0) ?? 0;
142
+ return codePoint < 32 || codePoint === 127;
143
+ })) {
144
+ throw new Error("host_parallel_promotion_invalid: repository identity is invalid.");
145
+ }
146
+ return normalized;
147
+ }
148
+ function normalizedUuid(value, label) {
149
+ const normalized = value.trim().toLowerCase();
150
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(normalized)) {
151
+ throw new Error(`host_parallel_promotion_invalid: ${label} must be a full UUID.`);
152
+ }
153
+ return normalized;
154
+ }
155
+ async function ensurePrivateDirectory(target) {
156
+ await fs.mkdir(target, { recursive: true, mode: 0o700 });
157
+ if (process.platform !== "win32")
158
+ await fs.chmod(target, 0o700);
159
+ }
@@ -0,0 +1,51 @@
1
+ import { HostPortBroker, type HostPortLaunch, type HostPortLeaseOwner, type HostPortReservation } from "./host-port-broker.js";
2
+ import { type HostRuntimePortAdapter, type HostRuntimePortConfig } from "./host-runtime-contract.js";
3
+ export type HostListenerOwnership = "unbound" | "owned" | "foreign";
4
+ export interface NodeRuntimeCommand {
5
+ readonly command: string;
6
+ readonly args: readonly string[];
7
+ readonly cwd: string;
8
+ readonly environment?: Readonly<Record<string, string>>;
9
+ }
10
+ export interface NodeRuntimeLaunchRequest extends NodeRuntimeCommand {
11
+ readonly framework: HostRuntimePortAdapter;
12
+ readonly port: HostPortReservation;
13
+ readonly baseUrl: string;
14
+ }
15
+ export interface NodeRuntimeCommandLauncher<T> {
16
+ readonly launch: (request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<T>;
17
+ readonly inspectListener: (value: T, request: NodeRuntimeLaunchRequest, signal?: AbortSignal) => Promise<HostListenerOwnership>;
18
+ readonly readinessDiagnostics?: (value: T) => Promise<NodeRuntimeReadinessDiagnostics>;
19
+ readonly stop: (value: T) => Promise<void>;
20
+ }
21
+ export interface NodeRuntimeReadinessDiagnostics {
22
+ readonly exited: boolean;
23
+ readonly exitCode?: number;
24
+ readonly stdoutTail?: string;
25
+ readonly stderrTail?: string;
26
+ }
27
+ export interface NodeHostRuntimeAdapterOptions {
28
+ readonly readinessTimeoutMs?: number;
29
+ readonly readinessPollIntervalMs?: number;
30
+ readonly httpProbeTimeoutMs?: number;
31
+ readonly probeHttp?: (url: string, timeoutMs: number, signal?: AbortSignal) => Promise<boolean>;
32
+ }
33
+ export interface NodeRuntimeAdapterLaunch<T> extends HostPortLaunch<T> {
34
+ readonly request: NodeRuntimeLaunchRequest;
35
+ }
36
+ /**
37
+ * Launches an explicitly configured HTTP project command beneath the caller's
38
+ * attempt supervisor. It never shells a command and never promotes a project
39
+ * by framework detection alone.
40
+ */
41
+ export declare class NodeHostRuntimeAdapter {
42
+ private readonly broker;
43
+ private readonly readinessTimeoutMs;
44
+ private readonly readinessPollIntervalMs;
45
+ private readonly httpProbeTimeoutMs;
46
+ private readonly probeHttp;
47
+ constructor(broker: HostPortBroker, options?: NodeHostRuntimeAdapterOptions);
48
+ launch<T>(owner: HostPortLeaseOwner, portConfig: HostRuntimePortConfig, command: NodeRuntimeCommand, launcher: NodeRuntimeCommandLauncher<T>, signal?: AbortSignal): Promise<NodeRuntimeAdapterLaunch<T>>;
49
+ private waitUntilReady;
50
+ }
51
+ export declare function buildNodeRuntimeLaunchRequest(framework: HostRuntimePortAdapter, portConfig: HostRuntimePortConfig, command: NodeRuntimeCommand, port: HostPortReservation): NodeRuntimeLaunchRequest;
@@ -0,0 +1,268 @@
1
+ import path from "node:path";
2
+ import { hostPortEnvironment, } from "./host-runtime-contract.js";
3
+ /**
4
+ * Launches an explicitly configured HTTP project command beneath the caller's
5
+ * attempt supervisor. It never shells a command and never promotes a project
6
+ * by framework detection alone.
7
+ */
8
+ export class NodeHostRuntimeAdapter {
9
+ broker;
10
+ readinessTimeoutMs;
11
+ readinessPollIntervalMs;
12
+ httpProbeTimeoutMs;
13
+ probeHttp;
14
+ constructor(broker, options = {}) {
15
+ this.broker = broker;
16
+ this.readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? 120_000, 100, 120_000, "readinessTimeoutMs");
17
+ this.readinessPollIntervalMs = boundedInteger(options.readinessPollIntervalMs ?? 100, 10, 5_000, "readinessPollIntervalMs");
18
+ this.httpProbeTimeoutMs = boundedInteger(options.httpProbeTimeoutMs ?? 1_000, 50, 10_000, "httpProbeTimeoutMs");
19
+ this.probeHttp = options.probeHttp ?? probeHttpReadiness;
20
+ }
21
+ async launch(owner, portConfig, command, launcher, signal) {
22
+ const framework = portConfig.adapter;
23
+ if (framework !== "node" && framework !== "vite" && framework !== "next" && framework !== "aspnetcore") {
24
+ throw new Error("node_runtime_adapter_invalid: port config must explicitly select node, vite, next, or aspnetcore.");
25
+ }
26
+ validateCommand(command);
27
+ if (!portConfig.baseUrlEnv || !portConfig.format) {
28
+ throw new Error("node_runtime_adapter_invalid: an HTTP baseUrlEnv and loopback format are required.");
29
+ }
30
+ let activeRequest = null;
31
+ const launched = await this.broker.launchWithRetry(owner, {
32
+ name: portConfig.name,
33
+ protocol: "tcp",
34
+ }, {
35
+ launch: async (port, launchSignal) => {
36
+ const request = buildNodeRuntimeLaunchRequest(framework, portConfig, command, port);
37
+ activeRequest = request;
38
+ return await launcher.launch(request, launchSignal);
39
+ },
40
+ verifyOwnership: async (value, _port, verifySignal) => {
41
+ if (!activeRequest)
42
+ throw new Error("node_runtime_adapter_state: launch request was not recorded.");
43
+ return await this.waitUntilReady(value, activeRequest, launcher, verifySignal);
44
+ },
45
+ stop: launcher.stop,
46
+ }, signal);
47
+ if (!activeRequest)
48
+ throw new Error("node_runtime_adapter_state: active launch request is missing.");
49
+ return { ...launched, request: activeRequest };
50
+ }
51
+ async waitUntilReady(value, request, launcher, signal) {
52
+ const deadline = Date.now() + this.readinessTimeoutMs;
53
+ let observedOwnership = "unbound";
54
+ while (Date.now() <= deadline) {
55
+ if (signal?.aborted)
56
+ throw cancellationError(signal);
57
+ const diagnostics = await launcher.readinessDiagnostics?.(value);
58
+ if (diagnostics?.exited) {
59
+ throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
60
+ }
61
+ const ownership = await launcher.inspectListener(value, request, signal);
62
+ observedOwnership = ownership;
63
+ if (ownership === "foreign")
64
+ return false;
65
+ if (ownership === "owned" && await this.probeHttp(request.baseUrl, this.httpProbeTimeoutMs, signal)) {
66
+ return true;
67
+ }
68
+ await wait(this.readinessPollIntervalMs, signal);
69
+ }
70
+ const diagnostics = await launcher.readinessDiagnostics?.(value);
71
+ if (diagnostics?.exited) {
72
+ throw new Error(readinessFailure("host_runtime_process_exited", `the supervised process exited with code ${diagnostics.exitCode ?? "unknown"} before its assigned runtime became ready`, request, diagnostics));
73
+ }
74
+ const code = observedOwnership === "owned"
75
+ ? "host_runtime_http_readiness_timeout"
76
+ : "host_runtime_listener_timeout";
77
+ const detail = observedOwnership === "owned"
78
+ ? `the supervised process owned its assigned loopback port but HTTP readiness did not succeed within ${this.readinessTimeoutMs}ms`
79
+ : `the supervised process did not bind its assigned loopback port within ${this.readinessTimeoutMs}ms`;
80
+ throw new Error(readinessFailure(code, detail, request, diagnostics));
81
+ }
82
+ }
83
+ function readinessFailure(code, detail, request, diagnostics) {
84
+ const evidence = [
85
+ boundedDiagnostic("stderr", diagnostics?.stderrTail),
86
+ boundedDiagnostic("stdout", diagnostics?.stdoutTail),
87
+ ].filter((value) => value !== null);
88
+ return `${code}: ${detail} (${request.baseUrl}).${evidence.length > 0 ? ` ${evidence.join(" ")}` : ""}`;
89
+ }
90
+ function boundedDiagnostic(label, value) {
91
+ const normalized = value?.replaceAll(/\s+/gu, " ").trim();
92
+ if (!normalized)
93
+ return null;
94
+ return `${label} tail: ${normalized.slice(-2_000)}`;
95
+ }
96
+ export function buildNodeRuntimeLaunchRequest(framework, portConfig, command, port) {
97
+ validateCommand(command);
98
+ if (port.hostIp !== "127.0.0.1" || port.protocol !== "tcp") {
99
+ throw new Error("node_runtime_adapter_invalid: project adapters require a TCP IPv4 loopback reservation.");
100
+ }
101
+ if (!portConfig.baseUrlEnv || !portConfig.format) {
102
+ throw new Error("node_runtime_adapter_invalid: an HTTP baseUrlEnv and loopback format are required.");
103
+ }
104
+ rejectConflictingFrameworkArguments(framework, command.args);
105
+ const portEnvironment = hostPortEnvironment(portConfig, port.hostIp, port.hostPort);
106
+ const baseUrl = portEnvironment[portConfig.baseUrlEnv];
107
+ if (!baseUrl)
108
+ throw new Error("node_runtime_adapter_invalid: base URL environment could not be rendered.");
109
+ const args = framework === "aspnetcore"
110
+ ? aspNetCoreArguments(command.command, command.args)
111
+ : [...command.args, ...frameworkArguments(framework, port.hostIp, port.hostPort)];
112
+ const adapterEnvironment = framework === "aspnetcore"
113
+ ? {
114
+ ASPNETCORE_URLS: baseUrl,
115
+ Hosting__Port: String(port.hostPort),
116
+ Harness__Reconciler__Hosted: "false",
117
+ Projects__RuntimeReadiness__Hosted: "false",
118
+ Runners__ServerLocalHeartbeat__Hosted: "false",
119
+ FORGE_RUNTIME_MODE: "host",
120
+ FORGE_DISPATCH_ATTEMPT_ID: port.dispatchAttemptId,
121
+ DOTNET_CLI_USE_MSBUILD_SERVER: "0",
122
+ DOTNET_NOLOGO: "1",
123
+ }
124
+ : { HOST: port.hostIp };
125
+ const forgeEnvironment = {
126
+ FORGE_RUNTIME_PORT_NAME: portConfig.name,
127
+ FORGE_RUNTIME_PORT: String(port.hostPort),
128
+ FORGE_RUNTIME_BASE_URL: baseUrl,
129
+ };
130
+ const expansionScope = {
131
+ ...command.environment,
132
+ ...portEnvironment,
133
+ ...adapterEnvironment,
134
+ ...forgeEnvironment,
135
+ };
136
+ return {
137
+ framework,
138
+ command: command.command,
139
+ args,
140
+ cwd: path.resolve(command.cwd),
141
+ environment: {
142
+ ...expandCommandEnvironment(command.environment, expansionScope),
143
+ ...portEnvironment,
144
+ ...adapterEnvironment,
145
+ ...forgeEnvironment,
146
+ },
147
+ port,
148
+ baseUrl,
149
+ };
150
+ }
151
+ function expandCommandEnvironment(environment, scope) {
152
+ return Object.fromEntries(Object.entries(environment ?? {}).map(([name, value]) => [
153
+ name,
154
+ value.replaceAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (_match, variable) => {
155
+ const resolved = scope[variable];
156
+ if (!resolved) {
157
+ throw new Error(`node_runtime_adapter_invalid: launch environment references unavailable variable '${variable}'.`);
158
+ }
159
+ return resolved;
160
+ }),
161
+ ]));
162
+ }
163
+ function frameworkArguments(framework, hostIp, hostPort) {
164
+ if (framework === "vite")
165
+ return ["--host", hostIp, "--port", String(hostPort), "--strictPort"];
166
+ if (framework === "next")
167
+ return ["--hostname", hostIp, "--port", String(hostPort)];
168
+ return [];
169
+ }
170
+ function rejectConflictingFrameworkArguments(framework, args) {
171
+ if (framework === "aspnetcore") {
172
+ const forbidden = new Set(["--urls", "--launch-profile", "-lp", "--no-launch-profile"]);
173
+ const conflict = args.find(argument => forbidden.has(argument)
174
+ || [...forbidden].some(flag => argument.startsWith(`${flag}=`)));
175
+ if (conflict) {
176
+ throw new Error(`node_runtime_adapter_invalid: command already supplies Forge-owned argument '${conflict}'.`);
177
+ }
178
+ return;
179
+ }
180
+ if (framework === "node")
181
+ return;
182
+ const forbidden = framework === "vite"
183
+ ? new Set(["--host", "--port", "--strictPort"])
184
+ : new Set(["--hostname", "--port", "-H", "-p"]);
185
+ const conflict = args.find(argument => forbidden.has(argument) || [...forbidden].some(flag => argument.startsWith(`${flag}=`)));
186
+ if (conflict) {
187
+ throw new Error(`node_runtime_adapter_invalid: command already supplies Forge-owned argument '${conflict}'.`);
188
+ }
189
+ }
190
+ function aspNetCoreArguments(command, args) {
191
+ const executable = path.basename(command).toLowerCase();
192
+ if (executable !== "dotnet" && executable !== "dotnet.exe") {
193
+ throw new Error("node_runtime_adapter_invalid: aspnetcore adapter requires the dotnet executable.");
194
+ }
195
+ if (args[0]?.toLowerCase() !== "run") {
196
+ throw new Error("node_runtime_adapter_invalid: aspnetcore adapter requires a dotnet run command.");
197
+ }
198
+ const applicationArguments = args.indexOf("--");
199
+ const insertionIndex = applicationArguments < 0 ? args.length : applicationArguments;
200
+ return [
201
+ ...args.slice(0, insertionIndex),
202
+ "--disable-build-servers",
203
+ "-p:UseSharedCompilation=false",
204
+ "-p:DefaultItemExcludesInProjectFolder=TestResults/**",
205
+ "--no-launch-profile",
206
+ ...args.slice(insertionIndex),
207
+ ];
208
+ }
209
+ function validateCommand(command) {
210
+ if (!boundedText(command.command, 1024)
211
+ || !path.isAbsolute(command.cwd)
212
+ || command.args.length > 128
213
+ || command.args.some(argument => !boundedText(argument, 4096))) {
214
+ throw new Error("node_runtime_adapter_invalid: command, arguments, or absolute working directory are invalid.");
215
+ }
216
+ for (const [name, value] of Object.entries(command.environment ?? {})) {
217
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/u.test(name) || !boundedText(value, 8192)) {
218
+ throw new Error("node_runtime_adapter_invalid: command environment is invalid.");
219
+ }
220
+ }
221
+ }
222
+ async function probeHttpReadiness(url, timeoutMs, signal) {
223
+ const timeout = AbortSignal.timeout(timeoutMs);
224
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
225
+ try {
226
+ const response = await fetch(url, { redirect: "manual", signal: combined });
227
+ await response.body?.cancel().catch(() => undefined);
228
+ return true;
229
+ }
230
+ catch (_error) {
231
+ if (signal?.aborted)
232
+ throw cancellationError(signal);
233
+ return false;
234
+ }
235
+ }
236
+ function wait(milliseconds, signal) {
237
+ if (signal?.aborted)
238
+ return Promise.reject(cancellationError(signal));
239
+ return new Promise((resolve, reject) => {
240
+ const timer = setTimeout(() => {
241
+ signal?.removeEventListener("abort", abort);
242
+ resolve();
243
+ }, milliseconds);
244
+ const abort = () => {
245
+ clearTimeout(timer);
246
+ reject(cancellationError(signal));
247
+ };
248
+ signal?.addEventListener("abort", abort, { once: true });
249
+ });
250
+ }
251
+ function boundedInteger(value, minimum, maximum, name) {
252
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
253
+ throw new Error(`node_runtime_adapter_invalid: ${name} must be between ${minimum} and ${maximum}.`);
254
+ }
255
+ return value;
256
+ }
257
+ function boundedText(value, maximumLength) {
258
+ return typeof value === "string"
259
+ && value.length > 0
260
+ && value.length <= maximumLength
261
+ && Array.from(value).every(character => {
262
+ const codePoint = character.codePointAt(0) ?? 0;
263
+ return codePoint > 31 && codePoint !== 127;
264
+ });
265
+ }
266
+ function cancellationError(signal) {
267
+ return new Error(String(signal?.reason ?? "Node runtime launch cancelled."));
268
+ }
@@ -0,0 +1,20 @@
1
+ import type { HostRuntimePlaywrightConfig } from "./host-runtime-contract.js";
2
+ export interface PlaywrightHostRuntimeContext {
3
+ readonly worktreePath: string;
4
+ readonly temporaryDirectory: string;
5
+ readonly testOutputDirectory: string;
6
+ readonly browserProfileDirectory: string;
7
+ readonly baseUrl: string;
8
+ }
9
+ export interface PreparedPlaywrightHostRuntime {
10
+ readonly configPath: string;
11
+ readonly environment: Readonly<Record<string, string>>;
12
+ }
13
+ /**
14
+ * Prepares a finite Playwright test boundary for a managed-host attempt.
15
+ * The generated overlay points tests at Forge's verified listener, disables
16
+ * Playwright-managed web servers, and keeps writable artifacts attempt-local.
17
+ */
18
+ export declare class PlaywrightHostRuntimeAdapter {
19
+ prepare(config: HostRuntimePlaywrightConfig, context: PlaywrightHostRuntimeContext): Promise<PreparedPlaywrightHostRuntime>;
20
+ }