@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.1ff37038366e
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.
- package/README.md +88 -2
- package/dist/bridge/bridge-chat.types.d.ts +111 -0
- package/dist/bridge/bridge-chat.types.js +401 -0
- package/dist/bridge/bridge.base.d.ts +4 -1
- package/dist/bridge/bridge.service.d.ts +72 -3
- package/dist/bridge/bridge.service.js +1563 -152
- package/dist/bridge/bridge.types.d.ts +46 -1
- package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
- package/dist/bridge/dispatch-result-outbox.js +62 -0
- package/dist/bridge/dispatch-result-worker.d.ts +2 -0
- package/dist/bridge/dispatch-result-worker.js +122 -0
- package/dist/bridge/internals.d.ts +2 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/bridge/readiness-result-outbox.d.ts +21 -0
- package/dist/bridge/readiness-result-outbox.js +54 -0
- package/dist/bridge/readiness-result-worker.d.ts +2 -0
- package/dist/bridge/readiness-result-worker.js +121 -0
- package/dist/bridge/runner-host-capabilities.d.ts +15 -0
- package/dist/bridge/runner-host-capabilities.js +93 -0
- package/dist/cleanup/cleanup.service.d.ts +1 -0
- package/dist/cleanup/cleanup.service.js +70 -9
- package/dist/cli/cli.help.js +2 -2
- package/dist/cli/cli.service.d.ts +14 -6
- package/dist/cli/cli.service.js +75 -16
- package/dist/cli/local-config.d.ts +1 -0
- package/dist/cli/local-config.js +4 -0
- package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
- package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
- package/dist/runtime/attempt-database-broker.d.ts +89 -0
- package/dist/runtime/attempt-database-broker.js +390 -0
- package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
- package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
- package/dist/runtime/attempt-path-operation.d.ts +21 -0
- package/dist/runtime/attempt-path-operation.js +94 -0
- package/dist/runtime/attempt-private-directory.d.ts +6 -0
- package/dist/runtime/attempt-private-directory.js +54 -0
- package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
- package/dist/runtime/attempt-process-supervisor.js +67 -20
- package/dist/runtime/canonical-source-file.d.ts +3 -0
- package/dist/runtime/canonical-source-file.js +54 -0
- package/dist/runtime/docker-engine-probe.d.ts +90 -0
- package/dist/runtime/docker-engine-probe.js +333 -0
- package/dist/runtime/generated-output-reset.d.ts +10 -0
- package/dist/runtime/generated-output-reset.js +62 -8
- package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
- package/dist/runtime/host-database-capability-boundary.js +62 -0
- package/dist/runtime/host-diagnostics.d.ts +11 -0
- package/dist/runtime/host-diagnostics.js +191 -0
- package/dist/runtime/host-execution-root.d.ts +4 -0
- package/dist/runtime/host-execution-root.js +39 -0
- package/dist/runtime/host-listener-inspector.d.ts +8 -1
- package/dist/runtime/host-listener-inspector.js +121 -60
- package/dist/runtime/host-runtime-contract.d.ts +12 -1
- package/dist/runtime/host-runtime-contract.js +168 -55
- package/dist/runtime/host-runtime-lease.d.ts +92 -8
- package/dist/runtime/host-runtime-lease.js +1141 -114
- package/dist/runtime/host-runtime-promotion.d.ts +12 -3
- package/dist/runtime/host-runtime-promotion.js +59 -12
- package/dist/runtime/memory-capacity.d.ts +70 -0
- package/dist/runtime/memory-capacity.js +123 -0
- package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
- package/dist/runtime/native-test-postgres-fallback.js +91 -0
- package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
- package/dist/runtime/node-host-runtime-adapter.js +28 -11
- package/dist/runtime/pinned-directory-process.d.ts +9 -0
- package/dist/runtime/pinned-directory-process.js +61 -0
- package/dist/runtime/pinned-source-directory.d.ts +17 -0
- package/dist/runtime/pinned-source-directory.js +94 -0
- package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
- package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
- package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
- package/dist/runtime/prepared-runtime-identity.js +218 -0
- package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
- package/dist/runtime/prepared-runtime-readiness.js +285 -0
- package/dist/runtime/runtime-isolation-lease.js +4 -1
- package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
- package/dist/runtime/workspace-resource-estimator.js +174 -0
- package/dist/sandbox/preflight.d.ts +31 -1
- package/dist/sandbox/preflight.js +237 -51
- package/dist/sandbox/repository-lock.d.ts +2 -1
- package/dist/sandbox/repository-lock.js +17 -8
- package/dist/sandbox/runtime.d.ts +6 -1
- package/dist/sandbox/runtime.js +132 -103
- package/dist/sandbox/sandbox.types.d.ts +7 -0
- package/dist/sandbox/workspace.service.js +2 -0
- package/dist/session/managed-attempt-tools.d.ts +17 -1
- package/dist/session/managed-attempt-tools.js +485 -31
- package/dist/session/managed-build-coordinator.d.ts +5 -0
- package/dist/session/managed-build-coordinator.js +105 -0
- package/dist/session/managed-build-receipt.d.ts +21 -0
- package/dist/session/managed-build-receipt.js +337 -0
- package/dist/session/managed-verification-timeouts.d.ts +8 -0
- package/dist/session/managed-verification-timeouts.js +17 -0
- package/dist/session/phase-completion.artifacts.d.ts +17 -0
- package/dist/session/phase-completion.artifacts.js +224 -22
- package/dist/session/phase-completion.d.ts +11 -1
- package/dist/session/phase-completion.helpers.d.ts +5 -0
- package/dist/session/phase-completion.helpers.js +30 -7
- package/dist/session/phase-completion.js +14 -3
- package/dist/session/phase-completion.prompts.js +17 -18
- package/dist/session/phase-completion.runtime.d.ts +3 -1
- package/dist/session/phase-completion.runtime.js +126 -17
- package/dist/session/phase-completion.schemas.js +49 -2
- package/dist/session/phase-completion.validation.d.ts +1 -0
- package/dist/session/phase-completion.validation.js +111 -21
- package/dist/session/pre-publication-validation.d.ts +75 -0
- package/dist/session/pre-publication-validation.js +661 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
- package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
- package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
- package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
- package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
- package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
- package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
- package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
- package/dist/session/provider-cli/provider-cli.process.js +422 -22
- package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
- package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
- package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
- package/dist/session/provider-cli/provider-cli.runtime.d.ts +8 -1
- package/dist/session/provider-cli/provider-cli.runtime.js +43 -3
- package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
- package/dist/session/provider-cli/provider-cli.service.js +771 -68
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
- package/dist/session/provider-cli/provider-output-state.js +361 -17
- package/dist/session/provider-cli/provider-usage.d.ts +7 -0
- package/dist/session/provider-cli/provider-usage.js +28 -5
- package/dist/session/session-redaction.js +434 -9
- package/dist/session/session.service.js +145 -23
- package/dist/session/session.types.d.ts +147 -2
- package/dist/session/session.types.js +587 -7
- package/dist/session/unreferenced-exports.d.ts +12 -0
- package/dist/session/unreferenced-exports.js +59 -0
- package/dist/shared/format/format-bytes.d.ts +1 -0
- package/dist/shared/format/format-bytes.js +9 -0
- package/dist/shared/git/git.js +2 -1
- package/dist/shared/path/path.d.ts +1 -0
- package/dist/shared/path/path.js +18 -0
- package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
- package/dist/shared/process/dotnet-toolchain.js +307 -0
- package/dist/shared/process/host-process-supervisor.d.ts +2 -0
- package/dist/shared/process/host-process-supervisor.js +57 -9
- package/dist/shared/process/process.d.ts +2 -0
- package/dist/shared/process/process.js +12 -7
- package/dist/shared/provider-capabilities.d.ts +3 -0
- package/dist/shared/provider-capabilities.js +85 -0
- package/dist/shared/redaction/redaction.d.ts +1 -0
- package/dist/shared/redaction/redaction.js +39 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/dependency-warmer.d.ts +41 -9
- package/dist/worktree/dependency-warmer.js +497 -22
- package/dist/worktree/index.d.ts +1 -1
- package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
- package/dist/worktree/story-qa-reference-hydrator.js +253 -0
- package/dist/worktree/worktree.service.d.ts +7 -0
- package/dist/worktree/worktree.service.js +253 -21
- package/dist/worktree/worktree.types.d.ts +7 -1
- package/package.json +7 -5
- package/runtime/windows-job-supervisor.ps1 +380 -14
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ApiKeySecretGrant, DispatchSandboxResourceRequest, RunOnceResultPacket, RunnerDispatchEvent } from "../session/session.types.js";
|
|
2
2
|
import type { SandboxFeatureMatrix } from "../sandbox/sandbox.types.js";
|
|
3
3
|
import type { HostRuntimeCapacitySnapshot } from "../runtime/host-runtime-lease.js";
|
|
4
|
+
import type { MemoryAdmissionDiagnostics } from "../runtime/memory-capacity.js";
|
|
5
|
+
import type { DockerEngineCapabilitySnapshot } from "../runtime/docker-engine-probe.js";
|
|
6
|
+
import type { BridgeChatCapability, ChatTurnCancelPacket } from "./bridge-chat.types.js";
|
|
4
7
|
export interface BridgeServiceConfig {
|
|
5
8
|
readonly serverUrl: string;
|
|
6
9
|
readonly runnerId: string;
|
|
@@ -14,7 +17,12 @@ export interface BridgeServiceConfig {
|
|
|
14
17
|
readonly sandboxPublicationMode?: "bridge_push" | "shared_filesystem";
|
|
15
18
|
readonly dockerExecutable: string;
|
|
16
19
|
readonly dockerContext: string;
|
|
20
|
+
readonly dockerDesktopRecoveryEnabled: boolean;
|
|
21
|
+
readonly dockerRecoveryFailureThreshold: number;
|
|
22
|
+
readonly dockerRecoveryCooldownMs: number;
|
|
23
|
+
readonly dockerRecoveryStartupTimeoutMs: number;
|
|
17
24
|
readonly sandboxStateRoot: string;
|
|
25
|
+
readonly sandboxExecutionRoot: string;
|
|
18
26
|
readonly requireRootlessDocker: boolean;
|
|
19
27
|
readonly diskHighWatermarkPercent: number;
|
|
20
28
|
readonly diskLowWatermarkPercent: number;
|
|
@@ -45,10 +53,13 @@ export interface BridgeServiceConfig {
|
|
|
45
53
|
readonly supportsDraftRunOnce: boolean;
|
|
46
54
|
readonly supportsDraftAttachments: boolean;
|
|
47
55
|
readonly supportsDraftImageAttachments: boolean;
|
|
56
|
+
readonly bridgeChat: BridgeChatCapability;
|
|
48
57
|
readonly version: string;
|
|
49
58
|
readonly protocolVersion: number;
|
|
50
59
|
readonly minServerVersion?: string | null;
|
|
51
60
|
readonly heartbeatIntervalMs: number;
|
|
61
|
+
readonly callbackAckTimeoutMs?: number;
|
|
62
|
+
readonly heartbeatDockerProbeWaitMs?: number;
|
|
52
63
|
readonly reconnectBackoffMs: readonly number[];
|
|
53
64
|
}
|
|
54
65
|
export interface BridgeRepoMapping {
|
|
@@ -72,6 +83,20 @@ export interface BridgeProviderStatus {
|
|
|
72
83
|
readonly version?: string | null;
|
|
73
84
|
readonly models?: readonly string[] | null;
|
|
74
85
|
}
|
|
86
|
+
export interface BridgeToolCapability {
|
|
87
|
+
readonly available: boolean;
|
|
88
|
+
readonly executable: string;
|
|
89
|
+
readonly version?: string | null;
|
|
90
|
+
}
|
|
91
|
+
export interface BridgeToolchainCapabilities {
|
|
92
|
+
readonly node: BridgeToolCapability;
|
|
93
|
+
readonly pnpm: BridgeToolCapability;
|
|
94
|
+
readonly dotnet: BridgeToolCapability;
|
|
95
|
+
readonly git: BridgeToolCapability;
|
|
96
|
+
readonly bash: BridgeToolCapability;
|
|
97
|
+
readonly powershell: BridgeToolCapability;
|
|
98
|
+
readonly docker?: BridgeToolCapability | null;
|
|
99
|
+
}
|
|
75
100
|
export interface BridgeCapabilities {
|
|
76
101
|
readonly schemaVersion?: number | null;
|
|
77
102
|
readonly activeSessionCount?: number | null;
|
|
@@ -83,6 +108,8 @@ export interface BridgeCapabilities {
|
|
|
83
108
|
readonly sandboxProfiles?: readonly string[] | null;
|
|
84
109
|
readonly runnerInstanceId?: string | null;
|
|
85
110
|
readonly trustDomainId?: string | null;
|
|
111
|
+
readonly executionFacilities?: readonly string[] | null;
|
|
112
|
+
readonly dockerEngine?: DockerEngineCapabilitySnapshot | null;
|
|
86
113
|
readonly sandboxRuntimes?: readonly string[] | null;
|
|
87
114
|
readonly sandboxRuntimeVersion?: string | null;
|
|
88
115
|
readonly sandboxFeatures?: SandboxFeatureMatrix | null;
|
|
@@ -110,6 +137,11 @@ export interface BridgeCapabilities {
|
|
|
110
137
|
readonly supportsDraftRunOnce?: boolean | null;
|
|
111
138
|
readonly supportsDraftAttachments?: boolean | null;
|
|
112
139
|
readonly supportsDraftImageAttachments?: boolean | null;
|
|
140
|
+
readonly bridgeChat?: BridgeChatCapability | null;
|
|
141
|
+
readonly toolchains?: BridgeToolchainCapabilities | null;
|
|
142
|
+
readonly playwrightProjects?: readonly string[] | null;
|
|
143
|
+
readonly browserLaunch?: boolean | null;
|
|
144
|
+
readonly providerConnectivity?: Readonly<Record<string, boolean>> | null;
|
|
113
145
|
}
|
|
114
146
|
export interface SandboxMemoryCapacityDiagnostics {
|
|
115
147
|
readonly defaultWorkerMemoryLimitBytes: number;
|
|
@@ -119,7 +151,10 @@ export interface SandboxMemoryCapacityDiagnostics {
|
|
|
119
151
|
readonly activeReservationBytes: number;
|
|
120
152
|
readonly daemonTotalBytes: number;
|
|
121
153
|
readonly daemonAvailableBytes: number;
|
|
154
|
+
readonly hostAvailableBytes: number;
|
|
155
|
+
readonly conservativeAvailableBytes: number;
|
|
122
156
|
readonly availableForAdmissionBytes: number;
|
|
157
|
+
readonly memoryAdmission: MemoryAdmissionDiagnostics;
|
|
123
158
|
}
|
|
124
159
|
export interface BridgeHelloRequest {
|
|
125
160
|
readonly version: string;
|
|
@@ -200,7 +235,9 @@ export interface BridgeLeaseUpdateRequest {
|
|
|
200
235
|
readonly runnerInstanceId: string;
|
|
201
236
|
readonly expiresAt: string;
|
|
202
237
|
}
|
|
203
|
-
export interface BridgeCancelRequest {
|
|
238
|
+
export interface BridgeCancelRequest extends ChatTurnCancelPacket {
|
|
239
|
+
/** When present, cancels only this pre-start reservation, never an active session. */
|
|
240
|
+
readonly dispatchAttemptId?: string | null;
|
|
204
241
|
readonly leaseId: string;
|
|
205
242
|
readonly fencingToken: string;
|
|
206
243
|
readonly reason?: string | null;
|
|
@@ -223,6 +260,7 @@ export interface BridgeAttemptSnapshotResponse extends BridgeCallbackAck {
|
|
|
223
260
|
readonly generatedAt?: string | null;
|
|
224
261
|
}
|
|
225
262
|
export type BridgeDispatchHandler = (dispatch: unknown) => Promise<void>;
|
|
263
|
+
export type BridgeChatTurnHandler = (dispatch: unknown) => Promise<void>;
|
|
226
264
|
export type BridgeOfferHandler = (dispatch: unknown) => Promise<void>;
|
|
227
265
|
export type BridgeStartHandler = (dispatch: unknown) => Promise<void>;
|
|
228
266
|
export type BridgeLeaseUpdateHandler = (request: BridgeLeaseUpdateRequest) => Promise<void>;
|
|
@@ -259,6 +297,13 @@ export interface BridgeRuntimeReadinessResult {
|
|
|
259
297
|
readonly qaAppLaunchTarget?: BridgeRuntimeReadinessQaLaunchTarget | null;
|
|
260
298
|
readonly checkedAt: string;
|
|
261
299
|
}
|
|
300
|
+
export interface BridgeRuntimeReadinessAccepted {
|
|
301
|
+
readonly schemaVersion: 1;
|
|
302
|
+
readonly requestId: string;
|
|
303
|
+
readonly fencingToken: string;
|
|
304
|
+
readonly runnerInstanceId: string;
|
|
305
|
+
readonly acceptedAt: string;
|
|
306
|
+
}
|
|
262
307
|
export interface BridgeRuntimeReadinessQaLaunchTarget {
|
|
263
308
|
readonly managedRuntimePort: string;
|
|
264
309
|
readonly routes: readonly string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BridgeDispatchResultCallback } from "./bridge.types.js";
|
|
2
|
+
export interface DurableDispatchResult {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly persistedAt: string;
|
|
5
|
+
readonly payload: BridgeDispatchResultCallback;
|
|
6
|
+
}
|
|
7
|
+
export interface DispatchResultOutbox {
|
|
8
|
+
persist(payload: BridgeDispatchResultCallback): Promise<void>;
|
|
9
|
+
pending(): Promise<readonly DurableDispatchResult[]>;
|
|
10
|
+
acknowledge(leaseId: string): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Persists the already-redacted terminal result before the Bridge attempts its
|
|
14
|
+
* SignalR callback. A transport or API restart can therefore delay delivery,
|
|
15
|
+
* but cannot turn successful provider work into a fabricated failed result.
|
|
16
|
+
*/
|
|
17
|
+
export declare class FileDispatchResultOutbox implements DispatchResultOutbox {
|
|
18
|
+
private readonly stateRoot;
|
|
19
|
+
private readonly writes;
|
|
20
|
+
constructor(stateRoot: string);
|
|
21
|
+
persist(payload: BridgeDispatchResultCallback): Promise<void>;
|
|
22
|
+
pending(): Promise<readonly DurableDispatchResult[]>;
|
|
23
|
+
acknowledge(leaseId: string): Promise<void>;
|
|
24
|
+
private operate;
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { assertCanonicalDirectory, createCanonicalDirectory } from "../runtime/attempt-private-directory.js";
|
|
4
|
+
import { runPinnedDirectoryProcess } from "../runtime/pinned-directory-process.js";
|
|
5
|
+
import { dispatchResultReceiptWorker } from "./dispatch-result-worker.js";
|
|
6
|
+
/**
|
|
7
|
+
* Persists the already-redacted terminal result before the Bridge attempts its
|
|
8
|
+
* SignalR callback. A transport or API restart can therefore delay delivery,
|
|
9
|
+
* but cannot turn successful provider work into a fabricated failed result.
|
|
10
|
+
*/
|
|
11
|
+
export class FileDispatchResultOutbox {
|
|
12
|
+
stateRoot;
|
|
13
|
+
writes = new Map();
|
|
14
|
+
constructor(stateRoot) {
|
|
15
|
+
this.stateRoot = stateRoot;
|
|
16
|
+
}
|
|
17
|
+
async persist(payload) {
|
|
18
|
+
const name = recordName(payload.leaseId);
|
|
19
|
+
const value = JSON.parse(JSON.stringify({ payload }));
|
|
20
|
+
const pending = this.writes.get(name);
|
|
21
|
+
if (pending) {
|
|
22
|
+
if (!isDeepStrictEqual(pending.value, value)) {
|
|
23
|
+
throw new Error("dispatch_result_conflict: lease identity reused with a different result.");
|
|
24
|
+
}
|
|
25
|
+
return await pending.write;
|
|
26
|
+
}
|
|
27
|
+
const write = this.operate({
|
|
28
|
+
kind: "persist",
|
|
29
|
+
name,
|
|
30
|
+
record: { schemaVersion: 1, persistedAt: new Date().toISOString(), ...value },
|
|
31
|
+
}).then(() => undefined);
|
|
32
|
+
this.writes.set(name, { value, write });
|
|
33
|
+
try {
|
|
34
|
+
await write;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (this.writes.get(name)?.write === write)
|
|
38
|
+
this.writes.delete(name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async pending() {
|
|
42
|
+
return JSON.parse(await this.operate({ kind: "pending" }));
|
|
43
|
+
}
|
|
44
|
+
async acknowledge(leaseId) {
|
|
45
|
+
await this.operate({ kind: "acknowledge", name: recordName(leaseId) });
|
|
46
|
+
}
|
|
47
|
+
async operate(operation) {
|
|
48
|
+
const root = path.resolve(this.stateRoot, "dispatch-results");
|
|
49
|
+
await createCanonicalDirectory(root);
|
|
50
|
+
const identity = await assertCanonicalDirectory(root);
|
|
51
|
+
if (!identity)
|
|
52
|
+
throw new Error("dispatch_result_invalid: outbox directory is missing.");
|
|
53
|
+
return await runPinnedDirectoryProcess(root, identity, dispatchResultReceiptWorker, operation, undefined, 256 * 1024 * 1024);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function recordName(leaseId) {
|
|
57
|
+
const value = leaseId.replaceAll("-", "").toLowerCase();
|
|
58
|
+
if (!/^[0-9a-f]{32}$/u.test(value)) {
|
|
59
|
+
throw new Error("dispatch_result_invalid: lease id must be a UUID.");
|
|
60
|
+
}
|
|
61
|
+
return `${value}.json`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Runs in a bounded async child. Receipt IO stays relative to verified, pinned cwd. */
|
|
2
|
+
export const dispatchResultReceiptWorker = String.raw `
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
6
|
+
const job = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
7
|
+
const maximumBytes = 64 * 1024 * 1024;
|
|
8
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
9
|
+
const sameReadMetadata = (a, b) => a.size === b.size && a.mtimeNs === b.mtimeNs
|
|
10
|
+
&& a.mode === b.mode && a.uid === b.uid && a.gid === b.gid && a.birthtimeNs === b.birthtimeNs;
|
|
11
|
+
const samePublicationMetadata = (a, b) => a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
|
|
12
|
+
const validDate = value => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
13
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
|
|
14
|
+
const validName = name => typeof name === 'string' && /^[0-9a-f]{32}\.json$/.test(name);
|
|
15
|
+
const validRecord = (record, name) => record && record.schemaVersion === 1
|
|
16
|
+
&& validDate(record.persistedAt) && record.payload
|
|
17
|
+
&& uuid(record.payload.leaseId) && uuid(record.payload.fencingToken)
|
|
18
|
+
&& (record.payload.dispatchAttemptId == null || uuid(record.payload.dispatchAttemptId))
|
|
19
|
+
&& (record.payload.runnerInstanceId == null || uuid(record.payload.runnerInstanceId))
|
|
20
|
+
&& record.payload.result && Number.isInteger(record.payload.result.exitCode)
|
|
21
|
+
&& typeof record.payload.result.summary === 'string'
|
|
22
|
+
&& Array.isArray(record.payload.result.events)
|
|
23
|
+
&& record.payload.leaseId.replaceAll('-', '').toLowerCase() + '.json' === name;
|
|
24
|
+
const read = name => {
|
|
25
|
+
if (!validName(name)) throw new Error('dispatch_result_invalid: invalid name');
|
|
26
|
+
let before;
|
|
27
|
+
try { before = fs.lstatSync(name, { bigint: true }); }
|
|
28
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
29
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(maximumBytes))
|
|
30
|
+
throw new Error('dispatch_result_invalid: not a bounded regular file');
|
|
31
|
+
const fd = fs.openSync(name, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
32
|
+
try {
|
|
33
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
34
|
+
if (!same(before, opened) || !opened.isFile() || opened.size !== before.size)
|
|
35
|
+
throw new Error('dispatch_result_invalid: receipt identity changed');
|
|
36
|
+
let baseline = opened;
|
|
37
|
+
let firstRead;
|
|
38
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
39
|
+
const bytes = Buffer.alloc(Number(opened.size));
|
|
40
|
+
let offset = 0;
|
|
41
|
+
while (offset < bytes.length) {
|
|
42
|
+
const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
43
|
+
if (!count) throw new Error('dispatch_result_invalid: incomplete receipt');
|
|
44
|
+
offset += count;
|
|
45
|
+
}
|
|
46
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
47
|
+
const current = fs.lstatSync(name, { bigint: true });
|
|
48
|
+
if (!same(opened, after) || !same(after, current) || !current.isFile() || current.isSymbolicLink()
|
|
49
|
+
|| !sameReadMetadata(baseline, after) || !sameReadMetadata(after, current))
|
|
50
|
+
throw new Error('dispatch_result_invalid: receipt changed during read');
|
|
51
|
+
if (!samePublicationMetadata(baseline, after) || !samePublicationMetadata(after, current)) {
|
|
52
|
+
// A publisher may unlink its temporary hard link between either stat.
|
|
53
|
+
// This is only a retry fence, never permission to trust unstable bytes.
|
|
54
|
+
if (attempt !== 0 || job.kind !== 'persist' || baseline.nlink !== 2n || current.nlink !== 1n
|
|
55
|
+
|| baseline.ctimeNs === current.ctimeNs
|
|
56
|
+
|| (!samePublicationMetadata(baseline, after) && !samePublicationMetadata(after, current)))
|
|
57
|
+
throw new Error('dispatch_result_invalid: receipt changed during read');
|
|
58
|
+
firstRead = bytes;
|
|
59
|
+
baseline = current;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (firstRead && !bytes.equals(firstRead))
|
|
63
|
+
throw new Error('dispatch_result_invalid: receipt changed during read');
|
|
64
|
+
const record = JSON.parse(bytes.toString('utf8'));
|
|
65
|
+
if (!validRecord(record, name)) throw new Error('dispatch_result_invalid: malformed or wrong identity');
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('dispatch_result_invalid: receipt changed during read');
|
|
69
|
+
} finally { fs.closeSync(fd); }
|
|
70
|
+
};
|
|
71
|
+
const identical = (existing, candidate) => {
|
|
72
|
+
if (!isDeepStrictEqual(existing.payload, candidate.payload))
|
|
73
|
+
throw new Error('dispatch_result_conflict: lease identity reused with a different result');
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
const cwd = fs.statSync('.', { bigint: true });
|
|
77
|
+
const real = fs.realpathSync('.');
|
|
78
|
+
if (!same(cwd, { dev: BigInt(job.dev), ino: BigInt(job.ino) })
|
|
79
|
+
|| (process.platform === 'win32' ? real.toLowerCase() !== job.parent.toLowerCase() : real !== job.parent))
|
|
80
|
+
throw new Error('dispatch_result_invalid: parent identity changed');
|
|
81
|
+
if (job.kind === 'persist') {
|
|
82
|
+
if (!validName(job.name) || !validRecord(job.record, job.name)) throw new Error('dispatch_result_invalid: invalid candidate');
|
|
83
|
+
const content = JSON.stringify(job.record) + '\n';
|
|
84
|
+
if (Buffer.byteLength(content) > maximumBytes) throw new Error('dispatch_result_invalid: receipt too large');
|
|
85
|
+
const existing = read(job.name);
|
|
86
|
+
if (existing) identical(existing, job.record);
|
|
87
|
+
else {
|
|
88
|
+
const temporary = job.name + '.' + randomUUID() + '.tmp';
|
|
89
|
+
let owned = false;
|
|
90
|
+
try {
|
|
91
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
92
|
+
owned = true;
|
|
93
|
+
try { fs.writeFileSync(fd, content, 'utf8'); fs.fsyncSync(fd); }
|
|
94
|
+
finally { fs.closeSync(fd); }
|
|
95
|
+
try { fs.linkSync(temporary, job.name); }
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== 'EEXIST') throw error;
|
|
98
|
+
const winner = read(job.name);
|
|
99
|
+
if (!winner) throw error;
|
|
100
|
+
identical(winner, job.record);
|
|
101
|
+
}
|
|
102
|
+
} finally { if (owned) fs.unlinkSync(temporary); }
|
|
103
|
+
}
|
|
104
|
+
} else if (job.kind === 'pending') {
|
|
105
|
+
const records = [];
|
|
106
|
+
const directory = fs.opendirSync('.');
|
|
107
|
+
try {
|
|
108
|
+
let entry;
|
|
109
|
+
while (records.length < 3 && (entry = directory.readSync()) !== null) {
|
|
110
|
+
if (!entry.isFile() || !validName(entry.name)) continue;
|
|
111
|
+
try { const record = read(entry.name); if (record) records.push(record); }
|
|
112
|
+
catch { /* Retain malformed evidence; never send it as a trusted callback. */ }
|
|
113
|
+
}
|
|
114
|
+
} finally { directory.closeSync(); }
|
|
115
|
+
records.sort((a, b) => a.persistedAt.localeCompare(b.persistedAt));
|
|
116
|
+
process.stdout.write(JSON.stringify(records));
|
|
117
|
+
} else if (job.kind === 'acknowledge') {
|
|
118
|
+
if (!validName(job.name)) throw new Error('dispatch_result_invalid: invalid name');
|
|
119
|
+
try { fs.unlinkSync(job.name); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
120
|
+
} else throw new Error('dispatch_result_invalid: invalid operation');
|
|
121
|
+
} catch (error) { process.stderr.write(error.message); process.exitCode = 1; }
|
|
122
|
+
`;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { SignalRBridgeConnection, SignalRBridgeConnectionFactory, defaultBridgeServiceConfig } from "./bridge.service.js";
|
|
2
|
+
export { bridgeChatProtocolVersion, bridgeChatUpdateRequiredCode, normalizeBridgeChatCapability, parseChatTurnDispatchPacket, unavailableBridgeChatCapability } from "./bridge-chat.types.js";
|
|
2
3
|
export type { BridgeConnection, BridgeConnectionFactory } from "./bridge.base.js";
|
|
4
|
+
export type { BridgeChatCapability, ChatTurnDispatchPacket } from "./bridge-chat.types.js";
|
|
3
5
|
export type { BridgeCallbackAck, BridgeCancelHandler, BridgeCancelRequest, BridgeCapabilities, BridgeDispatchEventCallback, BridgeDispatchHandler, BridgeDispatchResultCallback, BridgeHeartbeatRequest, BridgeHelloRequest, BridgeRepoMapping, BridgeServiceConfig } from "./bridge.types.js";
|
package/dist/bridge/internals.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
1
|
export { SignalRBridgeConnection, SignalRBridgeConnectionFactory, defaultBridgeServiceConfig } from "./bridge.service.js";
|
|
2
|
+
export { bridgeChatProtocolVersion, bridgeChatUpdateRequiredCode, normalizeBridgeChatCapability, parseChatTurnDispatchPacket, unavailableBridgeChatCapability } from "./bridge-chat.types.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BridgeRuntimeReadinessResult } from "./bridge.types.js";
|
|
2
|
+
export interface DurableReadinessResult {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly expiresAt: string;
|
|
5
|
+
readonly persistedAt: string;
|
|
6
|
+
readonly result: BridgeRuntimeReadinessResult;
|
|
7
|
+
}
|
|
8
|
+
export interface ReadinessResultOutbox {
|
|
9
|
+
persist(result: BridgeRuntimeReadinessResult, expiresAt: string): Promise<void>;
|
|
10
|
+
pending(): Promise<readonly DurableReadinessResult[]>;
|
|
11
|
+
acknowledge(requestId: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare class FileReadinessResultOutbox implements ReadinessResultOutbox {
|
|
14
|
+
private readonly stateRoot;
|
|
15
|
+
private readonly writes;
|
|
16
|
+
constructor(stateRoot: string);
|
|
17
|
+
persist(result: BridgeRuntimeReadinessResult, expiresAt: string): Promise<void>;
|
|
18
|
+
pending(): Promise<readonly DurableReadinessResult[]>;
|
|
19
|
+
acknowledge(requestId: string): Promise<void>;
|
|
20
|
+
private operate;
|
|
21
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { assertCanonicalDirectory, createCanonicalDirectory } from "../runtime/attempt-private-directory.js";
|
|
4
|
+
import { runPinnedDirectoryProcess } from "../runtime/pinned-directory-process.js";
|
|
5
|
+
import { readinessReceiptWorker } from "./readiness-result-worker.js";
|
|
6
|
+
export class FileReadinessResultOutbox {
|
|
7
|
+
stateRoot;
|
|
8
|
+
writes = new Map();
|
|
9
|
+
constructor(stateRoot) {
|
|
10
|
+
this.stateRoot = stateRoot;
|
|
11
|
+
}
|
|
12
|
+
async persist(result, expiresAt) {
|
|
13
|
+
const name = recordName(result.requestId);
|
|
14
|
+
// Capture caller-owned data before yielding. Compare semantic JSON, not key order.
|
|
15
|
+
const value = JSON.parse(JSON.stringify({ result, expiresAt }));
|
|
16
|
+
const pending = this.writes.get(name);
|
|
17
|
+
if (pending) {
|
|
18
|
+
if (!isDeepStrictEqual(pending.value, value))
|
|
19
|
+
throw new Error("runtime_readiness_result_conflict: request identity reused.");
|
|
20
|
+
return await pending.write;
|
|
21
|
+
}
|
|
22
|
+
const write = this.operate({ kind: "persist", name, record: {
|
|
23
|
+
schemaVersion: 1, persistedAt: new Date().toISOString(), ...value,
|
|
24
|
+
} }).then(() => undefined);
|
|
25
|
+
this.writes.set(name, { value, write });
|
|
26
|
+
try {
|
|
27
|
+
await write;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
if (this.writes.get(name)?.write === write)
|
|
31
|
+
this.writes.delete(name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async pending() {
|
|
35
|
+
return JSON.parse(await this.operate({ kind: "pending" }));
|
|
36
|
+
}
|
|
37
|
+
async acknowledge(requestId) {
|
|
38
|
+
await this.operate({ kind: "acknowledge", name: recordName(requestId) });
|
|
39
|
+
}
|
|
40
|
+
async operate(operation) {
|
|
41
|
+
const root = path.resolve(this.stateRoot, "readiness-results");
|
|
42
|
+
await createCanonicalDirectory(root);
|
|
43
|
+
const identity = await assertCanonicalDirectory(root);
|
|
44
|
+
if (!identity)
|
|
45
|
+
throw new Error("runtime_readiness_result_invalid: outbox directory is missing.");
|
|
46
|
+
return await runPinnedDirectoryProcess(root, identity, readinessReceiptWorker, operation, undefined, 256 * 256 * 1024 + 1024);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function recordName(requestId) {
|
|
50
|
+
const value = requestId.replaceAll("-", "").toLowerCase();
|
|
51
|
+
if (!/^[0-9a-f]{32}$/u.test(value))
|
|
52
|
+
throw new Error("runtime_readiness_result_invalid: request id must be a UUID.");
|
|
53
|
+
return `${value}.json`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Runs in a bounded async child. Receipt IO stays relative to verified, pinned cwd. */
|
|
2
|
+
export const readinessReceiptWorker = String.raw `
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
6
|
+
const job = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
7
|
+
const maximumBytes = 256 * 1024;
|
|
8
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
9
|
+
const sameReadMetadata = (a, b) => a.size === b.size && a.mtimeNs === b.mtimeNs
|
|
10
|
+
&& a.mode === b.mode && a.uid === b.uid && a.gid === b.gid && a.birthtimeNs === b.birthtimeNs;
|
|
11
|
+
const samePublicationMetadata = (a, b) => a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
|
|
12
|
+
const validDate = value => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
13
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
14
|
+
const validName = name => typeof name === 'string' && /^[0-9a-f]{32}\.json$/.test(name);
|
|
15
|
+
const validRecord = (record, name) => record && record.schemaVersion === 1
|
|
16
|
+
&& validDate(record.expiresAt) && validDate(record.persistedAt) && record.result
|
|
17
|
+
&& record.result.schemaVersion === 1 && uuid(record.result.requestId) && uuid(record.result.fencingToken)
|
|
18
|
+
&& uuid(record.result.runnerInstanceId) && validDate(record.result.checkedAt)
|
|
19
|
+
&& ['parallel_ready', 'serialized', 'failed'].includes(record.result.status)
|
|
20
|
+
&& record.result.requestId.replaceAll('-', '').toLowerCase() + '.json' === name;
|
|
21
|
+
const read = name => {
|
|
22
|
+
if (!validName(name)) throw new Error('runtime_readiness_result_invalid: invalid name');
|
|
23
|
+
let before;
|
|
24
|
+
try { before = fs.lstatSync(name, { bigint: true }); }
|
|
25
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
26
|
+
// Atomic publishers may briefly hold their own second hard link until cleanup.
|
|
27
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(maximumBytes))
|
|
28
|
+
throw new Error('runtime_readiness_result_invalid: not a bounded regular file');
|
|
29
|
+
const fd = fs.openSync(name, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
30
|
+
try {
|
|
31
|
+
const opened = fs.fstatSync(fd, { bigint: true });
|
|
32
|
+
if (!same(before, opened) || !opened.isFile() || opened.size !== before.size)
|
|
33
|
+
throw new Error('runtime_readiness_result_invalid: receipt identity changed');
|
|
34
|
+
let baseline = opened;
|
|
35
|
+
let firstRead;
|
|
36
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
37
|
+
const bytes = Buffer.alloc(Number(opened.size));
|
|
38
|
+
let offset = 0;
|
|
39
|
+
while (offset < bytes.length) {
|
|
40
|
+
const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
41
|
+
if (!count) throw new Error('runtime_readiness_result_invalid: incomplete receipt');
|
|
42
|
+
offset += count;
|
|
43
|
+
}
|
|
44
|
+
const after = fs.fstatSync(fd, { bigint: true });
|
|
45
|
+
const current = fs.lstatSync(name, { bigint: true });
|
|
46
|
+
if (!same(opened, after) || !same(after, current) || !current.isFile() || current.isSymbolicLink()
|
|
47
|
+
|| !sameReadMetadata(baseline, after) || !sameReadMetadata(after, current))
|
|
48
|
+
throw new Error('runtime_readiness_result_invalid: receipt changed during read');
|
|
49
|
+
if (!samePublicationMetadata(baseline, after) || !samePublicationMetadata(after, current)) {
|
|
50
|
+
// A publisher may unlink its temporary hard link between either stat.
|
|
51
|
+
// This is only a retry fence, never permission to trust unstable bytes.
|
|
52
|
+
if (attempt !== 0 || job.kind !== 'persist' || baseline.nlink !== 2n || current.nlink !== 1n
|
|
53
|
+
|| baseline.ctimeNs === current.ctimeNs
|
|
54
|
+
|| (!samePublicationMetadata(baseline, after) && !samePublicationMetadata(after, current)))
|
|
55
|
+
throw new Error('runtime_readiness_result_invalid: receipt changed during read');
|
|
56
|
+
firstRead = bytes;
|
|
57
|
+
baseline = current;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (firstRead && !bytes.equals(firstRead))
|
|
61
|
+
throw new Error('runtime_readiness_result_invalid: receipt changed during read');
|
|
62
|
+
const record = JSON.parse(bytes.toString('utf8'));
|
|
63
|
+
if (!validRecord(record, name)) throw new Error('runtime_readiness_result_invalid: malformed or wrong identity');
|
|
64
|
+
return record;
|
|
65
|
+
}
|
|
66
|
+
throw new Error('runtime_readiness_result_invalid: receipt changed during read');
|
|
67
|
+
} finally { fs.closeSync(fd); }
|
|
68
|
+
};
|
|
69
|
+
const identical = (existing, candidate) => {
|
|
70
|
+
if (!isDeepStrictEqual(existing.result, candidate.result) || existing.expiresAt !== candidate.expiresAt)
|
|
71
|
+
throw new Error('runtime_readiness_result_conflict: request identity reused');
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const cwd = fs.statSync('.', { bigint: true });
|
|
75
|
+
const real = fs.realpathSync('.');
|
|
76
|
+
if (!same(cwd, { dev: BigInt(job.dev), ino: BigInt(job.ino) })
|
|
77
|
+
|| (process.platform === 'win32' ? real.toLowerCase() !== job.parent.toLowerCase() : real !== job.parent))
|
|
78
|
+
throw new Error('runtime_readiness_result_invalid: parent identity changed');
|
|
79
|
+
if (job.kind === 'persist') {
|
|
80
|
+
if (!validName(job.name) || !validRecord(job.record, job.name)) throw new Error('runtime_readiness_result_invalid: invalid candidate');
|
|
81
|
+
const content = JSON.stringify(job.record) + '\n';
|
|
82
|
+
if (Buffer.byteLength(content) > maximumBytes) throw new Error('runtime_readiness_result_invalid: receipt too large');
|
|
83
|
+
const existing = read(job.name);
|
|
84
|
+
if (existing) identical(existing, job.record);
|
|
85
|
+
else {
|
|
86
|
+
const temporary = job.name + '.' + randomUUID() + '.tmp';
|
|
87
|
+
let owned = false;
|
|
88
|
+
try {
|
|
89
|
+
const fd = fs.openSync(temporary, 'wx', 0o600);
|
|
90
|
+
owned = true;
|
|
91
|
+
try { fs.writeFileSync(fd, content, 'utf8'); fs.fsyncSync(fd); }
|
|
92
|
+
finally { fs.closeSync(fd); }
|
|
93
|
+
try { fs.linkSync(temporary, job.name); }
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code !== 'EEXIST') throw error;
|
|
96
|
+
const winner = read(job.name);
|
|
97
|
+
if (!winner) throw error;
|
|
98
|
+
identical(winner, job.record);
|
|
99
|
+
}
|
|
100
|
+
} finally { if (owned) fs.unlinkSync(temporary); }
|
|
101
|
+
}
|
|
102
|
+
} else if (job.kind === 'pending') {
|
|
103
|
+
const records = [];
|
|
104
|
+
const directory = fs.opendirSync('.');
|
|
105
|
+
try {
|
|
106
|
+
let entry;
|
|
107
|
+
while (records.length < 256 && (entry = directory.readSync()) !== null) {
|
|
108
|
+
if (!entry.isFile() || !validName(entry.name)) continue;
|
|
109
|
+
try { const record = read(entry.name); if (record) records.push(record); }
|
|
110
|
+
catch { /* Retain malformed evidence; never send it as a trusted callback. */ }
|
|
111
|
+
}
|
|
112
|
+
} finally { directory.closeSync(); }
|
|
113
|
+
records.sort((a, b) => a.persistedAt.localeCompare(b.persistedAt));
|
|
114
|
+
process.stdout.write(JSON.stringify(records));
|
|
115
|
+
} else if (job.kind === 'acknowledge') {
|
|
116
|
+
if (!validName(job.name)) throw new Error('runtime_readiness_result_invalid: invalid name');
|
|
117
|
+
// unlink is relative to pinned cwd and never follows a replaced leaf link.
|
|
118
|
+
try { fs.unlinkSync(job.name); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
119
|
+
} else throw new Error('runtime_readiness_result_invalid: invalid operation');
|
|
120
|
+
} catch (error) { process.stderr.write(error.message); process.exitCode = 1; }
|
|
121
|
+
`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BridgeServiceConfig, BridgeToolchainCapabilities } from "./bridge.types.js";
|
|
2
|
+
export interface RunnerHostCapabilitySnapshot {
|
|
3
|
+
readonly toolchains: BridgeToolchainCapabilities;
|
|
4
|
+
readonly playwrightProjects: readonly string[];
|
|
5
|
+
readonly browserLaunch: boolean;
|
|
6
|
+
readonly providerConnectivity: Readonly<Record<string, boolean>>;
|
|
7
|
+
}
|
|
8
|
+
export interface RunnerHostCapabilityProbe {
|
|
9
|
+
detect(config: BridgeServiceConfig): Promise<RunnerHostCapabilitySnapshot>;
|
|
10
|
+
}
|
|
11
|
+
export declare class ProcessRunnerHostCapabilityProbe implements RunnerHostCapabilityProbe {
|
|
12
|
+
detect(config: BridgeServiceConfig): Promise<RunnerHostCapabilitySnapshot>;
|
|
13
|
+
}
|
|
14
|
+
export declare function isUnavailablePlaywrightConfigError(error: unknown): boolean;
|
|
15
|
+
export declare function emptyRunnerHostCapabilitySnapshot(): RunnerHostCapabilitySnapshot;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export class ProcessRunnerHostCapabilityProbe {
|
|
7
|
+
async detect(config) {
|
|
8
|
+
const [pnpm, dotnet, git, bash, pwsh, windowsPowerShell] = await Promise.all([
|
|
9
|
+
probe("pnpm", ["--version"]),
|
|
10
|
+
probe("dotnet", ["--version"]),
|
|
11
|
+
probe("git", ["--version"]),
|
|
12
|
+
probe("bash", ["--version"]),
|
|
13
|
+
probe("pwsh", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]),
|
|
14
|
+
process.platform === "win32"
|
|
15
|
+
? probe("powershell.exe", ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"])
|
|
16
|
+
: Promise.resolve(unavailable("powershell.exe")),
|
|
17
|
+
]);
|
|
18
|
+
const playwrightProjects = await detectPlaywrightProjects(config.repoMappings);
|
|
19
|
+
const browserLaunch = playwrightProjects.length > 0
|
|
20
|
+
|| config.repoMappings.some(mapping => Boolean(mapping.qaAppLaunchTarget?.url
|
|
21
|
+
|| mapping.qaAppLaunchTarget?.command
|
|
22
|
+
|| mapping.qaAppLaunchTarget?.managed_runtime_port));
|
|
23
|
+
return {
|
|
24
|
+
toolchains: {
|
|
25
|
+
node: { available: true, executable: process.execPath, version: process.version },
|
|
26
|
+
pnpm,
|
|
27
|
+
dotnet,
|
|
28
|
+
git,
|
|
29
|
+
bash,
|
|
30
|
+
powershell: pwsh.available ? pwsh : windowsPowerShell,
|
|
31
|
+
},
|
|
32
|
+
playwrightProjects,
|
|
33
|
+
browserLaunch,
|
|
34
|
+
providerConnectivity: Object.fromEntries(Object.entries(config.providerStatuses)
|
|
35
|
+
.map(([provider, status]) => [provider, status.signedIn === true])),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function probe(executable, args) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await execFileAsync(executable, [...args], { encoding: "utf8", timeout: 5_000 });
|
|
42
|
+
const version = `${result.stdout || result.stderr}`.trim().split(/\r?\n/u)[0] ?? "";
|
|
43
|
+
return { available: true, executable, version };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return unavailable(executable);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function unavailable(executable) {
|
|
50
|
+
return { available: false, executable, version: null };
|
|
51
|
+
}
|
|
52
|
+
async function detectPlaywrightProjects(repoMappings) {
|
|
53
|
+
const projects = new Set();
|
|
54
|
+
for (const mapping of repoMappings) {
|
|
55
|
+
if (!mapping.localPath)
|
|
56
|
+
continue;
|
|
57
|
+
const candidates = [
|
|
58
|
+
"playwright.config.ts", "playwright.config.js", "playwright.config.mjs",
|
|
59
|
+
"tests/e2e/playwright.config.ts", "tests/e2e/playwright.config.js", "tests/e2e/playwright.config.mjs",
|
|
60
|
+
];
|
|
61
|
+
for (const relative of candidates) {
|
|
62
|
+
try {
|
|
63
|
+
const source = await fs.readFile(path.join(mapping.localPath, relative), "utf8");
|
|
64
|
+
for (const match of source.matchAll(/\bname\s*:\s*["']([^"']+)["']/gu))
|
|
65
|
+
projects.add(match[1]);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (!isUnavailablePlaywrightConfigError(error))
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...projects].sort((left, right) => left.localeCompare(right));
|
|
74
|
+
}
|
|
75
|
+
export function isUnavailablePlaywrightConfigError(error) {
|
|
76
|
+
const code = error?.code;
|
|
77
|
+
return code === "ENOENT" || code === "EACCES" || code === "EPERM" || code === "ENOTDIR";
|
|
78
|
+
}
|
|
79
|
+
export function emptyRunnerHostCapabilitySnapshot() {
|
|
80
|
+
return {
|
|
81
|
+
toolchains: {
|
|
82
|
+
node: { available: true, executable: process.execPath, version: process.version },
|
|
83
|
+
pnpm: unavailable("pnpm"),
|
|
84
|
+
dotnet: unavailable("dotnet"),
|
|
85
|
+
git: unavailable("git"),
|
|
86
|
+
bash: unavailable("bash"),
|
|
87
|
+
powershell: unavailable(process.platform === "win32" ? "powershell.exe" : "pwsh"),
|
|
88
|
+
},
|
|
89
|
+
playwrightProjects: [],
|
|
90
|
+
browserLaunch: false,
|
|
91
|
+
providerConnectivity: {},
|
|
92
|
+
};
|
|
93
|
+
}
|