@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.bfa11ebe98c6

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 (160) hide show
  1. package/README.md +88 -2
  2. package/dist/bridge/bridge-chat.types.d.ts +111 -0
  3. package/dist/bridge/bridge-chat.types.js +401 -0
  4. package/dist/bridge/bridge.base.d.ts +4 -1
  5. package/dist/bridge/bridge.service.d.ts +72 -3
  6. package/dist/bridge/bridge.service.js +1562 -152
  7. package/dist/bridge/bridge.types.d.ts +46 -1
  8. package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
  9. package/dist/bridge/dispatch-result-outbox.js +62 -0
  10. package/dist/bridge/dispatch-result-worker.d.ts +2 -0
  11. package/dist/bridge/dispatch-result-worker.js +122 -0
  12. package/dist/bridge/internals.d.ts +2 -0
  13. package/dist/bridge/internals.js +1 -0
  14. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  15. package/dist/bridge/readiness-result-outbox.js +54 -0
  16. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  17. package/dist/bridge/readiness-result-worker.js +121 -0
  18. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  19. package/dist/bridge/runner-host-capabilities.js +93 -0
  20. package/dist/cleanup/cleanup.service.d.ts +1 -0
  21. package/dist/cleanup/cleanup.service.js +70 -9
  22. package/dist/cli/cli.help.js +2 -2
  23. package/dist/cli/cli.service.d.ts +14 -6
  24. package/dist/cli/cli.service.js +75 -16
  25. package/dist/cli/local-config.d.ts +1 -0
  26. package/dist/cli/local-config.js +4 -0
  27. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  28. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  29. package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
  30. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  31. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  32. package/dist/runtime/attempt-path-operation.js +94 -0
  33. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  34. package/dist/runtime/attempt-private-directory.js +54 -0
  35. package/dist/runtime/attempt-process-supervisor.d.ts +11 -0
  36. package/dist/runtime/attempt-process-supervisor.js +67 -20
  37. package/dist/runtime/canonical-source-file.d.ts +3 -0
  38. package/dist/runtime/canonical-source-file.js +54 -0
  39. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  40. package/dist/runtime/docker-engine-probe.js +333 -0
  41. package/dist/runtime/generated-output-reset.d.ts +10 -0
  42. package/dist/runtime/generated-output-reset.js +62 -8
  43. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  44. package/dist/runtime/host-database-capability-boundary.js +62 -0
  45. package/dist/runtime/host-diagnostics.d.ts +11 -0
  46. package/dist/runtime/host-diagnostics.js +191 -0
  47. package/dist/runtime/host-execution-root.d.ts +4 -0
  48. package/dist/runtime/host-execution-root.js +39 -0
  49. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  50. package/dist/runtime/host-listener-inspector.js +121 -60
  51. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  52. package/dist/runtime/host-runtime-contract.js +107 -53
  53. package/dist/runtime/host-runtime-lease.d.ts +90 -8
  54. package/dist/runtime/host-runtime-lease.js +1097 -108
  55. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  56. package/dist/runtime/host-runtime-promotion.js +59 -12
  57. package/dist/runtime/memory-capacity.d.ts +70 -0
  58. package/dist/runtime/memory-capacity.js +123 -0
  59. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  60. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  61. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  62. package/dist/runtime/node-host-runtime-adapter.js +28 -11
  63. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  64. package/dist/runtime/pinned-directory-process.js +61 -0
  65. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  66. package/dist/runtime/pinned-source-directory.js +94 -0
  67. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  68. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  69. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  70. package/dist/runtime/prepared-runtime-identity.js +218 -0
  71. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  72. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  73. package/dist/runtime/runtime-isolation-lease.js +4 -1
  74. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  75. package/dist/runtime/workspace-resource-estimator.js +174 -0
  76. package/dist/sandbox/preflight.d.ts +31 -1
  77. package/dist/sandbox/preflight.js +237 -51
  78. package/dist/sandbox/repository-lock.d.ts +2 -1
  79. package/dist/sandbox/repository-lock.js +17 -8
  80. package/dist/sandbox/runtime.d.ts +6 -1
  81. package/dist/sandbox/runtime.js +132 -103
  82. package/dist/sandbox/sandbox.types.d.ts +7 -0
  83. package/dist/sandbox/workspace.service.js +2 -0
  84. package/dist/session/managed-attempt-tools.d.ts +17 -1
  85. package/dist/session/managed-attempt-tools.js +481 -31
  86. package/dist/session/managed-build-coordinator.d.ts +5 -0
  87. package/dist/session/managed-build-coordinator.js +105 -0
  88. package/dist/session/managed-build-receipt.d.ts +21 -0
  89. package/dist/session/managed-build-receipt.js +337 -0
  90. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  91. package/dist/session/managed-verification-timeouts.js +17 -0
  92. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  93. package/dist/session/phase-completion.artifacts.js +224 -22
  94. package/dist/session/phase-completion.d.ts +11 -1
  95. package/dist/session/phase-completion.helpers.d.ts +5 -0
  96. package/dist/session/phase-completion.helpers.js +30 -7
  97. package/dist/session/phase-completion.js +14 -3
  98. package/dist/session/phase-completion.prompts.js +17 -18
  99. package/dist/session/phase-completion.runtime.d.ts +3 -1
  100. package/dist/session/phase-completion.runtime.js +118 -16
  101. package/dist/session/phase-completion.schemas.js +49 -2
  102. package/dist/session/phase-completion.validation.d.ts +1 -0
  103. package/dist/session/phase-completion.validation.js +111 -21
  104. package/dist/session/pre-publication-validation.d.ts +75 -0
  105. package/dist/session/pre-publication-validation.js +657 -0
  106. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  107. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  108. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  109. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  110. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  111. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  112. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  113. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  114. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  115. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  116. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  117. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  118. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  119. package/dist/session/provider-cli/provider-cli.runtime.d.ts +8 -1
  120. package/dist/session/provider-cli/provider-cli.runtime.js +40 -3
  121. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  122. package/dist/session/provider-cli/provider-cli.service.js +771 -68
  123. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  124. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  125. package/dist/session/provider-cli/provider-output-state.js +361 -17
  126. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  127. package/dist/session/provider-cli/provider-usage.js +28 -5
  128. package/dist/session/session-redaction.js +434 -9
  129. package/dist/session/session.service.js +145 -23
  130. package/dist/session/session.types.d.ts +147 -2
  131. package/dist/session/session.types.js +587 -7
  132. package/dist/session/unreferenced-exports.d.ts +12 -0
  133. package/dist/session/unreferenced-exports.js +59 -0
  134. package/dist/shared/format/format-bytes.d.ts +1 -0
  135. package/dist/shared/format/format-bytes.js +9 -0
  136. package/dist/shared/git/git.js +2 -1
  137. package/dist/shared/path/path.d.ts +1 -0
  138. package/dist/shared/path/path.js +18 -0
  139. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  140. package/dist/shared/process/dotnet-toolchain.js +307 -0
  141. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  142. package/dist/shared/process/host-process-supervisor.js +57 -9
  143. package/dist/shared/process/process.d.ts +2 -0
  144. package/dist/shared/process/process.js +12 -7
  145. package/dist/shared/provider-capabilities.d.ts +3 -0
  146. package/dist/shared/provider-capabilities.js +85 -0
  147. package/dist/shared/redaction/redaction.d.ts +1 -0
  148. package/dist/shared/redaction/redaction.js +39 -3
  149. package/dist/version.d.ts +1 -1
  150. package/dist/version.js +1 -1
  151. package/dist/worktree/dependency-warmer.d.ts +41 -9
  152. package/dist/worktree/dependency-warmer.js +497 -22
  153. package/dist/worktree/index.d.ts +1 -1
  154. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  155. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  156. package/dist/worktree/worktree.service.d.ts +7 -0
  157. package/dist/worktree/worktree.service.js +253 -21
  158. package/dist/worktree/worktree.types.d.ts +7 -1
  159. package/package.json +7 -5
  160. 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,2 @@
1
+ /** Runs in a bounded async child. Receipt IO stays relative to verified, pinned cwd. */
2
+ export declare const dispatchResultReceiptWorker: string;
@@ -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";
@@ -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,2 @@
1
+ /** Runs in a bounded async child. Receipt IO stays relative to verified, pinned cwd. */
2
+ export declare const readinessReceiptWorker: string;
@@ -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
+ }