@hraness/direct 0.7.5
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/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/core/index.js +162 -0
- package/dist/index-1csg00w4.js +1167 -0
- package/dist/index-6mdfd2ey.js +464 -0
- package/dist/index-7n1h75n6.js +616 -0
- package/dist/index.js +232 -0
- package/dist/react.js +32 -0
- package/dist/testing/index.js +1069 -0
- package/dist/tooling/bombadil.js +2117 -0
- package/dist/tooling/browser-verification-entry.js +1499 -0
- package/dist/tooling/bundle-boundary.js +119 -0
- package/dist/web.js +605 -0
- package/package.json +179 -0
- package/skills/direct/AGENTS.md +13 -0
- package/skills/direct/SKILL.md +49 -0
- package/skills/direct/agents/openai.yaml +4 -0
- package/skills/direct/references/adoption.md +131 -0
- package/skills/direct/references/install.md +91 -0
- package/skills/direct/references/verification.md +247 -0
- package/src/core/coverage.ts +336 -0
- package/src/core/definition.ts +378 -0
- package/src/core/effects.ts +88 -0
- package/src/core/fixture.ts +185 -0
- package/src/core/ids.ts +77 -0
- package/src/core/index.ts +13 -0
- package/src/core/json-value.ts +7 -0
- package/src/core/json.ts +593 -0
- package/src/core/query.ts +230 -0
- package/src/core/reason.ts +16 -0
- package/src/core/resource.ts +10 -0
- package/src/core/result.ts +19 -0
- package/src/core/runtime.ts +229 -0
- package/src/core/scenario.ts +149 -0
- package/src/core/store.ts +784 -0
- package/src/index.ts +51 -0
- package/src/react.ts +54 -0
- package/src/testing/activity.ts +228 -0
- package/src/testing/coverage-binding.ts +99 -0
- package/src/testing/evidence.ts +59 -0
- package/src/testing/index.ts +22 -0
- package/src/testing/manifest.ts +559 -0
- package/src/testing/probe.ts +446 -0
- package/src/testing/scripted-transport.ts +775 -0
- package/src/testing/session.ts +525 -0
- package/src/tooling/bombadil-campaign.ts +288 -0
- package/src/tooling/bombadil-internal.d.ts +46 -0
- package/src/tooling/bombadil-runner.ts +1424 -0
- package/src/tooling/bombadil.ts +27 -0
- package/src/tooling/browser-verification-entry.ts +32 -0
- package/src/tooling/browser-verification.ts +916 -0
- package/src/tooling/bundle-boundary.ts +159 -0
- package/src/web/browser-bridge.ts +296 -0
- package/src/web/browser.ts +277 -0
- package/src/web/fetch-firewall.ts +251 -0
- package/src/web.ts +27 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LOG_LIMIT = 12_000;
|
|
6
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
|
|
7
|
+
const DEFAULT_REUSE_PROBE_INTERVAL_MS = 250;
|
|
8
|
+
const DEFAULT_STOP_TIMEOUT_MS = 3_000;
|
|
9
|
+
const MAX_RENDERED_ERROR_LENGTH = 4_096;
|
|
10
|
+
const MAX_ERROR_CAUSE_DEPTH = 8;
|
|
11
|
+
|
|
12
|
+
export type BrowserVerificationArguments =
|
|
13
|
+
| { readonly kind: "help" }
|
|
14
|
+
| { readonly kind: "run"; readonly baseUrl: string };
|
|
15
|
+
|
|
16
|
+
export interface AgentBrowser {
|
|
17
|
+
readonly close: () => Promise<void>;
|
|
18
|
+
readonly evaluate: (expression: string) => Promise<unknown>;
|
|
19
|
+
readonly readBodyText: () => Promise<string>;
|
|
20
|
+
readonly restart: () => Promise<void>;
|
|
21
|
+
readonly run: (arguments_: readonly string[]) => Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DirectBrowserManifest {
|
|
25
|
+
readonly active: Readonly<{
|
|
26
|
+
readonly activationHash: string;
|
|
27
|
+
readonly route: string;
|
|
28
|
+
readonly scenario: string;
|
|
29
|
+
readonly source: "scenario" | "fixture";
|
|
30
|
+
}>;
|
|
31
|
+
readonly catalogHash: string;
|
|
32
|
+
readonly coverage: unknown;
|
|
33
|
+
readonly defaultScenario: unknown;
|
|
34
|
+
readonly queries: unknown;
|
|
35
|
+
readonly scenarios: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DirectBrowserProbe {
|
|
39
|
+
readonly activationHash: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DirectBrowserContract<
|
|
43
|
+
Manifest extends DirectBrowserManifest = DirectBrowserManifest,
|
|
44
|
+
Probe extends DirectBrowserProbe = DirectBrowserProbe,
|
|
45
|
+
> {
|
|
46
|
+
readonly manifest: Manifest;
|
|
47
|
+
readonly probe: Probe;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DirectBrowserContractExpectation {
|
|
51
|
+
readonly source: "scenario" | "fixture";
|
|
52
|
+
readonly scenario: string;
|
|
53
|
+
readonly route: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DirectBrowserContractEnvelope {
|
|
57
|
+
readonly bridgeSchema: unknown;
|
|
58
|
+
readonly manifest: unknown;
|
|
59
|
+
readonly probe: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type DirectBrowserParserResult<Value> =
|
|
63
|
+
| Readonly<{ readonly ok: true; readonly value: Value }>
|
|
64
|
+
| Readonly<{
|
|
65
|
+
readonly error: Readonly<{ readonly message: string }>;
|
|
66
|
+
readonly ok: false;
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
export interface DirectBrowserProtocol<
|
|
70
|
+
Manifest extends DirectBrowserManifest,
|
|
71
|
+
Probe extends DirectBrowserProbe,
|
|
72
|
+
> {
|
|
73
|
+
readonly bridgeSchema: string;
|
|
74
|
+
readonly parseManifest: (input: unknown) => DirectBrowserParserResult<Manifest>;
|
|
75
|
+
readonly parseProbe: (input: unknown) => DirectBrowserParserResult<Probe>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseDirectBrowserContractEnvelope(
|
|
79
|
+
input: unknown,
|
|
80
|
+
): DirectBrowserContractEnvelope {
|
|
81
|
+
try {
|
|
82
|
+
if (
|
|
83
|
+
typeof input !== "object"
|
|
84
|
+
|| input === null
|
|
85
|
+
|| Array.isArray(input)
|
|
86
|
+
|| Object.keys(input).length !== 3
|
|
87
|
+
|| !Object.hasOwn(input, "bridgeSchema")
|
|
88
|
+
|| !Object.hasOwn(input, "manifest")
|
|
89
|
+
|| !Object.hasOwn(input, "probe")
|
|
90
|
+
) {
|
|
91
|
+
throw new Error("invalid");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
bridgeSchema: Reflect.get(input, "bridgeSchema"),
|
|
95
|
+
manifest: Reflect.get(input, "manifest"),
|
|
96
|
+
probe: Reflect.get(input, "probe"),
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error("Direct browser contract has an invalid envelope");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function directCatalogIdentity(manifest: DirectBrowserManifest): string {
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
queries: manifest.queries,
|
|
106
|
+
defaultScenario: manifest.defaultScenario,
|
|
107
|
+
scenarios: manifest.scenarios,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Prove that independently loaded scenario pages expose one identical Direct
|
|
113
|
+
* catalog, then return the single coverage snapshot bound to that catalog.
|
|
114
|
+
*/
|
|
115
|
+
export function bindDirectScenarioCatalog<Manifest extends DirectBrowserManifest>(
|
|
116
|
+
manifests: readonly Manifest[],
|
|
117
|
+
): Manifest["coverage"] {
|
|
118
|
+
const baseline = manifests[0];
|
|
119
|
+
if (baseline === undefined) {
|
|
120
|
+
throw new Error("Direct scenario verification requires at least one session manifest");
|
|
121
|
+
}
|
|
122
|
+
const baselineCoverage = JSON.stringify(baseline.coverage);
|
|
123
|
+
const baselineCatalog = directCatalogIdentity(baseline);
|
|
124
|
+
for (const [index, manifest] of manifests.entries()) {
|
|
125
|
+
if (manifest.catalogHash !== baseline.catalogHash) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Direct scenario ${String(index)} exposed catalog ${manifest.catalogHash} instead of ${baseline.catalogHash}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (JSON.stringify(manifest.coverage) !== baselineCoverage) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Direct scenario ${String(index)} exposed different coverage for catalog ${baseline.catalogHash}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (directCatalogIdentity(manifest) !== baselineCatalog) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Direct scenario ${String(index)} exposed different public metadata for catalog ${baseline.catalogHash}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return baseline.coverage;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bind post-interaction evidence to the exact catalog and activation sampled
|
|
146
|
+
* before the interaction. Probe counters may advance; their session identity
|
|
147
|
+
* may not.
|
|
148
|
+
*/
|
|
149
|
+
export function bindDirectBrowserContractEvidence<
|
|
150
|
+
Manifest extends DirectBrowserManifest,
|
|
151
|
+
Probe extends DirectBrowserProbe,
|
|
152
|
+
>(
|
|
153
|
+
initial: DirectBrowserContract<Manifest, Probe>,
|
|
154
|
+
final: DirectBrowserContract<Manifest, Probe>,
|
|
155
|
+
retainedProbe: DirectBrowserProbe = final.probe,
|
|
156
|
+
): DirectBrowserContract<Manifest, Probe> {
|
|
157
|
+
if (directCatalogIdentity(final.manifest) !== directCatalogIdentity(initial.manifest)) {
|
|
158
|
+
throw new Error("Direct public catalog metadata changed during verification");
|
|
159
|
+
}
|
|
160
|
+
if (JSON.stringify(final.manifest.coverage) !== JSON.stringify(initial.manifest.coverage)) {
|
|
161
|
+
throw new Error("Direct coverage changed during verification");
|
|
162
|
+
}
|
|
163
|
+
if (final.manifest.catalogHash !== initial.manifest.catalogHash) {
|
|
164
|
+
throw new Error("Direct catalog hash changed during verification");
|
|
165
|
+
}
|
|
166
|
+
if (JSON.stringify(final.manifest.active) !== JSON.stringify(initial.manifest.active)) {
|
|
167
|
+
throw new Error("Direct activation identity changed during verification");
|
|
168
|
+
}
|
|
169
|
+
if (
|
|
170
|
+
initial.probe.activationHash !== initial.manifest.active.activationHash
|
|
171
|
+
|| final.probe.activationHash !== final.manifest.active.activationHash
|
|
172
|
+
|| retainedProbe.activationHash !== final.manifest.active.activationHash
|
|
173
|
+
) {
|
|
174
|
+
throw new Error("Direct probe identity changed during verification");
|
|
175
|
+
}
|
|
176
|
+
return final;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read one atomic Direct bridge sample and bind its exact manifest, active
|
|
181
|
+
* scenario, product route, and probe identity before product assertions run.
|
|
182
|
+
*/
|
|
183
|
+
export function createDirectBrowserContractReader<
|
|
184
|
+
Manifest extends DirectBrowserManifest,
|
|
185
|
+
Probe extends DirectBrowserProbe,
|
|
186
|
+
>(
|
|
187
|
+
protocol: DirectBrowserProtocol<Manifest, Probe>,
|
|
188
|
+
): (
|
|
189
|
+
browser: Pick<AgentBrowser, "evaluate">,
|
|
190
|
+
expectation: DirectBrowserContractExpectation,
|
|
191
|
+
) => Promise<DirectBrowserContract<Manifest, Probe>> {
|
|
192
|
+
return async (browser, expectation) => {
|
|
193
|
+
const envelope = parseDirectBrowserContractEnvelope(
|
|
194
|
+
await browser.evaluate(`(() => {
|
|
195
|
+
const bridge = window.__direct;
|
|
196
|
+
return {
|
|
197
|
+
bridgeSchema: bridge?.schema,
|
|
198
|
+
manifest: bridge?.manifest,
|
|
199
|
+
probe: typeof bridge?.snapshot === "function" ? bridge.snapshot() : undefined,
|
|
200
|
+
};
|
|
201
|
+
})()`),
|
|
202
|
+
);
|
|
203
|
+
if (envelope.bridgeSchema !== protocol.bridgeSchema) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Direct browser bridge schema must be ${protocol.bridgeSchema}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const manifest = protocol.parseManifest(envelope.manifest);
|
|
209
|
+
if (!manifest.ok) {
|
|
210
|
+
throw new Error(`Direct session manifest is invalid: ${manifest.error.message}`);
|
|
211
|
+
}
|
|
212
|
+
const probe = protocol.parseProbe(envelope.probe);
|
|
213
|
+
if (!probe.ok) {
|
|
214
|
+
throw new Error(`Direct probe is invalid: ${probe.error.message}`);
|
|
215
|
+
}
|
|
216
|
+
if (manifest.value.active.source !== expectation.source) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Direct activated from ${manifest.value.active.source} instead of ${expectation.source}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (String(manifest.value.active.scenario) !== expectation.scenario) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Direct activated ${String(manifest.value.active.scenario)} instead of ${expectation.scenario}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (manifest.value.active.route !== expectation.route) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Direct scenario ${expectation.scenario} activated route ${manifest.value.active.route} instead of ${expectation.route}`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (manifest.value.active.activationHash !== probe.value.activationHash) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
"Direct session manifest and probe identify different activations",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
manifest: manifest.value,
|
|
238
|
+
probe: probe.value,
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface ManagedVerificationServer {
|
|
244
|
+
readonly exited: Promise<unknown>;
|
|
245
|
+
readonly exitCode: () => number | null;
|
|
246
|
+
readonly output: Promise<string>;
|
|
247
|
+
readonly terminate: () => void;
|
|
248
|
+
readonly kill: () => void;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export type ServerLease =
|
|
252
|
+
| { readonly source: "reused" }
|
|
253
|
+
| { readonly source: "started"; readonly server: ManagedVerificationServer };
|
|
254
|
+
|
|
255
|
+
export interface ArtifactRun {
|
|
256
|
+
readonly artifactRoot: string;
|
|
257
|
+
readonly generatedAt: string;
|
|
258
|
+
readonly manifestPath: string;
|
|
259
|
+
readonly runDirectory: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Serializes explicit Chrome flags for agent-browser without a shell boundary. */
|
|
263
|
+
export function serializeAgentBrowserLaunchArguments(
|
|
264
|
+
launchArguments: readonly string[],
|
|
265
|
+
): string {
|
|
266
|
+
for (const argument of launchArguments) {
|
|
267
|
+
if (!argument.startsWith("--") || argument.includes("\n") || argument.includes(",")) {
|
|
268
|
+
throw new Error(`agent-browser launch arguments must be comma-free Chrome flags, received ${JSON.stringify(argument)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return launchArguments.join(",");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Builds a fresh agent-browser process environment without inheriting an
|
|
276
|
+
* attached browser, persistent profile, restored state, or ambient flags.
|
|
277
|
+
*/
|
|
278
|
+
export function isolatedAgentBrowserEnvironment(options: {
|
|
279
|
+
readonly configPath: string;
|
|
280
|
+
readonly defaultTimeoutMs: number;
|
|
281
|
+
readonly idleTimeoutMs?: number;
|
|
282
|
+
readonly inheritedEnvironment: Readonly<Record<string, string | undefined>>;
|
|
283
|
+
readonly launchArguments?: readonly string[];
|
|
284
|
+
readonly session: string;
|
|
285
|
+
}): Record<string, string | undefined> {
|
|
286
|
+
const environment = { ...options.inheritedEnvironment };
|
|
287
|
+
for (const variable of Object.keys(environment)) {
|
|
288
|
+
if (variable.startsWith("AGENT_BROWSER_")) Reflect.deleteProperty(environment, variable);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
...environment,
|
|
292
|
+
AGENT_BROWSER_CONFIG: options.configPath,
|
|
293
|
+
AGENT_BROWSER_DEFAULT_TIMEOUT: String(options.defaultTimeoutMs),
|
|
294
|
+
AGENT_BROWSER_IDLE_TIMEOUT_MS: String(
|
|
295
|
+
options.idleTimeoutMs ?? options.defaultTimeoutMs + 60_000,
|
|
296
|
+
),
|
|
297
|
+
...(options.launchArguments === undefined
|
|
298
|
+
? {}
|
|
299
|
+
: { AGENT_BROWSER_ARGS: serializeAgentBrowserLaunchArguments(options.launchArguments) }),
|
|
300
|
+
AGENT_BROWSER_NAMESPACE: options.session,
|
|
301
|
+
AGENT_BROWSER_RESTORE_SAVE: "never",
|
|
302
|
+
AGENT_BROWSER_SESSION: options.session,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Keeps the namespace-backed Unix socket path below macOS's 103-byte limit. */
|
|
307
|
+
export function boundedAgentBrowserSessionName(
|
|
308
|
+
prefix: string,
|
|
309
|
+
processId: number,
|
|
310
|
+
nonce: string,
|
|
311
|
+
): string {
|
|
312
|
+
const boundedPrefix = prefix
|
|
313
|
+
.replaceAll(/[^a-zA-Z0-9_-]+/g, "-")
|
|
314
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
315
|
+
.slice(0, 6) || "verify";
|
|
316
|
+
const boundedProcessId = Math.max(0, Math.trunc(processId)).toString(36).slice(-6);
|
|
317
|
+
const boundedNonce = nonce.replaceAll(/[^a-zA-Z0-9]+/g, "").slice(0, 6) || "run";
|
|
318
|
+
return `${boundedPrefix}-${boundedProcessId}-${boundedNonce}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Keeps diagnostics useful without repeating large browser programs or batches. */
|
|
322
|
+
export function renderAgentBrowserCommand(arguments_: readonly string[]): string {
|
|
323
|
+
const [command, payload] = arguments_;
|
|
324
|
+
if (command === "eval" && payload !== undefined) {
|
|
325
|
+
return `${command} (${payload.length} character payload)`;
|
|
326
|
+
}
|
|
327
|
+
if (command === "batch") {
|
|
328
|
+
return `${command} (${arguments_.slice(1).join("\n").length} character payload)`;
|
|
329
|
+
}
|
|
330
|
+
return arguments_.join(" ");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export const agentBrowserCloseProcessTimeoutMs = 10_000;
|
|
334
|
+
|
|
335
|
+
export function agentBrowserProcessTimeoutMs(
|
|
336
|
+
arguments_: readonly string[],
|
|
337
|
+
defaultTimeoutMs: number,
|
|
338
|
+
): number {
|
|
339
|
+
const defaultProcessTimeoutMs = defaultTimeoutMs + 5_000;
|
|
340
|
+
return arguments_[0] === "close"
|
|
341
|
+
? Math.min(defaultProcessTimeoutMs, agentBrowserCloseProcessTimeoutMs)
|
|
342
|
+
: defaultProcessTimeoutMs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function truncateRenderedError(value: string): string {
|
|
346
|
+
if (value.length <= MAX_RENDERED_ERROR_LENGTH) return value;
|
|
347
|
+
return `${value.slice(0, MAX_RENDERED_ERROR_LENGTH - 1)}…`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function readForeignProperty(
|
|
351
|
+
value: object | ((...arguments_: never[]) => unknown),
|
|
352
|
+
key: PropertyKey,
|
|
353
|
+
): { readonly ok: true; readonly value: unknown } | { readonly ok: false } {
|
|
354
|
+
try {
|
|
355
|
+
return { ok: true, value: Reflect.get(value, key) };
|
|
356
|
+
} catch {
|
|
357
|
+
return { ok: false };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isUnknownArray(value: unknown): value is readonly unknown[] {
|
|
362
|
+
return Array.isArray(value);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isNonArrayObject(value: unknown): value is object {
|
|
366
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isNonEmptyStringArray(value: unknown): value is readonly [string, ...string[]] {
|
|
370
|
+
return isUnknownArray(value)
|
|
371
|
+
&& value.length > 0
|
|
372
|
+
&& value.every((entry) => typeof entry === "string");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderUnknownAtDepth(value: unknown, seen: WeakSet<object>, depth: number): string {
|
|
376
|
+
if (typeof value === "string") return truncateRenderedError(value);
|
|
377
|
+
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
378
|
+
if (seen.has(value)) return "[Circular]";
|
|
379
|
+
if (depth >= MAX_ERROR_CAUSE_DEPTH) return "[Cause depth exceeded]";
|
|
380
|
+
seen.add(value);
|
|
381
|
+
|
|
382
|
+
const message = readForeignProperty(value, "message");
|
|
383
|
+
if (message.ok && typeof message.value === "string") {
|
|
384
|
+
const name = readForeignProperty(value, "name");
|
|
385
|
+
const label = name.ok && typeof name.value === "string" && name.value.length > 0
|
|
386
|
+
? name.value
|
|
387
|
+
: "Error";
|
|
388
|
+
const cause = readForeignProperty(value, "cause");
|
|
389
|
+
const renderedCause = cause.ok && cause.value !== undefined
|
|
390
|
+
? `; caused by ${renderUnknownAtDepth(cause.value, seen, depth + 1)}`
|
|
391
|
+
: "";
|
|
392
|
+
return truncateRenderedError(`${label}: ${message.value}${renderedCause}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
const encoded = JSON.stringify(value);
|
|
398
|
+
if (encoded !== undefined) return truncateRenderedError(encoded);
|
|
399
|
+
} catch {
|
|
400
|
+
// Fall through to guarded coercion for cycles, proxies, and foreign toJSON hooks.
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
return truncateRenderedError(String(value));
|
|
404
|
+
} catch {
|
|
405
|
+
return "Unknown failure";
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Render foreign failures without trusting prototypes, getters, causes, or unbounded output. */
|
|
410
|
+
export function renderUnknown(value: unknown): string {
|
|
411
|
+
return renderUnknownAtDepth(value, new WeakSet<object>(), 0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function tail(value: string, maximumLength = DEFAULT_LOG_LIMIT): string {
|
|
415
|
+
return value.length <= maximumLength ? value : value.slice(-maximumLength);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function normalizeRootHttpOrigin(input: string): string {
|
|
419
|
+
let url: URL;
|
|
420
|
+
try {
|
|
421
|
+
url = new URL(input);
|
|
422
|
+
} catch {
|
|
423
|
+
throw new Error("--base-url must be an absolute HTTP URL");
|
|
424
|
+
}
|
|
425
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
426
|
+
throw new Error("--base-url must use http: or https:");
|
|
427
|
+
}
|
|
428
|
+
if (url.username !== "" || url.password !== "") {
|
|
429
|
+
throw new Error("--base-url cannot contain credentials");
|
|
430
|
+
}
|
|
431
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
432
|
+
throw new Error("--base-url must point to the server root without a query string or fragment");
|
|
433
|
+
}
|
|
434
|
+
return url.origin;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function parseBaseUrlArguments(
|
|
438
|
+
arguments_: readonly string[],
|
|
439
|
+
defaultBaseUrl: string,
|
|
440
|
+
): BrowserVerificationArguments {
|
|
441
|
+
let baseUrl = defaultBaseUrl;
|
|
442
|
+
let receivedBaseUrl = false;
|
|
443
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
444
|
+
const argument = arguments_[index];
|
|
445
|
+
if (argument === undefined) continue;
|
|
446
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
447
|
+
if (argument.startsWith("--base-url=")) {
|
|
448
|
+
if (receivedBaseUrl) throw new Error("--base-url may be provided only once");
|
|
449
|
+
receivedBaseUrl = true;
|
|
450
|
+
baseUrl = argument.slice("--base-url=".length);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (argument === "--base-url") {
|
|
454
|
+
if (receivedBaseUrl) throw new Error("--base-url may be provided only once");
|
|
455
|
+
const value = arguments_[index + 1];
|
|
456
|
+
if (value === undefined || value.startsWith("-")) {
|
|
457
|
+
throw new Error("--base-url requires a value");
|
|
458
|
+
}
|
|
459
|
+
receivedBaseUrl = true;
|
|
460
|
+
baseUrl = value;
|
|
461
|
+
index += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`Unknown argument at position ${String(index + 1)}`);
|
|
465
|
+
}
|
|
466
|
+
return { kind: "run", baseUrl: normalizeRootHttpOrigin(baseUrl) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function canAutomaticallyStartLocalServer(
|
|
470
|
+
baseUrl: string,
|
|
471
|
+
localHosts: ReadonlySet<string> = new Set(["127.0.0.1", "localhost"]),
|
|
472
|
+
): boolean {
|
|
473
|
+
const url = new URL(normalizeRootHttpOrigin(baseUrl));
|
|
474
|
+
return url.protocol === "http:" && localHosts.has(url.hostname);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function parseAgentBrowserEnvelope(source: string): unknown {
|
|
478
|
+
let input: unknown;
|
|
479
|
+
try {
|
|
480
|
+
input = JSON.parse(source) as unknown;
|
|
481
|
+
} catch {
|
|
482
|
+
throw new Error("agent-browser did not return one JSON document");
|
|
483
|
+
}
|
|
484
|
+
if (
|
|
485
|
+
typeof input !== "object"
|
|
486
|
+
|| input === null
|
|
487
|
+
|| Array.isArray(input)
|
|
488
|
+
|| typeof Reflect.get(input, "success") !== "boolean"
|
|
489
|
+
|| !Object.hasOwn(input, "data")
|
|
490
|
+
|| !Object.hasOwn(input, "error")
|
|
491
|
+
) {
|
|
492
|
+
throw new Error("agent-browser returned an invalid envelope");
|
|
493
|
+
}
|
|
494
|
+
if (!Reflect.get(input, "success")) {
|
|
495
|
+
throw new Error(`agent-browser reported failure: ${renderUnknown(Reflect.get(input, "error"))}`);
|
|
496
|
+
}
|
|
497
|
+
return Reflect.get(input, "data");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function parseAgentBrowserBatchEnvelope(source: string): readonly unknown[] {
|
|
501
|
+
let input: unknown;
|
|
502
|
+
try {
|
|
503
|
+
input = JSON.parse(source) as unknown;
|
|
504
|
+
} catch {
|
|
505
|
+
throw new Error("agent-browser batch did not return one JSON document");
|
|
506
|
+
}
|
|
507
|
+
if (!isUnknownArray(input) || input.length === 0) {
|
|
508
|
+
throw new Error("agent-browser batch returned an invalid envelope");
|
|
509
|
+
}
|
|
510
|
+
return input.map((entry, index) => {
|
|
511
|
+
if (
|
|
512
|
+
!isNonArrayObject(entry)
|
|
513
|
+
|| !Object.hasOwn(entry, "command")
|
|
514
|
+
|| !Object.hasOwn(entry, "success")
|
|
515
|
+
|| !Object.hasOwn(entry, "result")
|
|
516
|
+
|| !Object.hasOwn(entry, "error")
|
|
517
|
+
) {
|
|
518
|
+
throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`);
|
|
519
|
+
}
|
|
520
|
+
const command = readForeignProperty(entry, "command");
|
|
521
|
+
const success = readForeignProperty(entry, "success");
|
|
522
|
+
const result = readForeignProperty(entry, "result");
|
|
523
|
+
const error = readForeignProperty(entry, "error");
|
|
524
|
+
if (
|
|
525
|
+
!command.ok
|
|
526
|
+
|| !isNonEmptyStringArray(command.value)
|
|
527
|
+
|| !success.ok
|
|
528
|
+
|| typeof success.value !== "boolean"
|
|
529
|
+
|| !result.ok
|
|
530
|
+
|| !error.ok
|
|
531
|
+
) {
|
|
532
|
+
throw new Error(`agent-browser batch returned an invalid envelope at position ${String(index + 1)}`);
|
|
533
|
+
}
|
|
534
|
+
if (!success.value) {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`agent-browser batch command ${String(index + 1)} (${renderAgentBrowserCommand(command.value)}) reported failure: ${renderUnknown(error.value)}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return result.value;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function createAgentBrowser(options: {
|
|
544
|
+
readonly repositoryRoot: string;
|
|
545
|
+
readonly sessionPrefix: string;
|
|
546
|
+
readonly defaultTimeoutMs?: number;
|
|
547
|
+
readonly idleTimeoutMs?: number;
|
|
548
|
+
readonly launchArguments?: readonly string[];
|
|
549
|
+
}): AgentBrowser {
|
|
550
|
+
const binary = join(options.repositoryRoot, "node_modules/.bin/agent-browser");
|
|
551
|
+
const createEnvironment = () => {
|
|
552
|
+
const session = boundedAgentBrowserSessionName(
|
|
553
|
+
options.sessionPrefix,
|
|
554
|
+
process.pid,
|
|
555
|
+
randomUUID(),
|
|
556
|
+
);
|
|
557
|
+
return isolatedAgentBrowserEnvironment({
|
|
558
|
+
configPath: join(options.repositoryRoot, "scripts/direct/agent-browser.verify.json"),
|
|
559
|
+
defaultTimeoutMs: options.defaultTimeoutMs ?? 35_000,
|
|
560
|
+
...(options.idleTimeoutMs === undefined
|
|
561
|
+
? {}
|
|
562
|
+
: { idleTimeoutMs: options.idleTimeoutMs }),
|
|
563
|
+
inheritedEnvironment: process.env,
|
|
564
|
+
...(options.launchArguments === undefined
|
|
565
|
+
? {}
|
|
566
|
+
: { launchArguments: options.launchArguments }),
|
|
567
|
+
session,
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
let environment = createEnvironment();
|
|
571
|
+
let used = false;
|
|
572
|
+
|
|
573
|
+
async function run(arguments_: readonly string[]): Promise<unknown> {
|
|
574
|
+
used = true;
|
|
575
|
+
const defaultTimeoutMs = options.defaultTimeoutMs ?? 35_000;
|
|
576
|
+
const commandArguments = arguments_[0] === "wait" && !arguments_.includes("--timeout")
|
|
577
|
+
? [...arguments_, "--timeout", String(defaultTimeoutMs)]
|
|
578
|
+
: arguments_;
|
|
579
|
+
const command = Bun.spawn([process.execPath, binary, "--json", ...commandArguments], {
|
|
580
|
+
cwd: options.repositoryRoot,
|
|
581
|
+
env: environment,
|
|
582
|
+
stdin: "ignore",
|
|
583
|
+
stdout: "pipe",
|
|
584
|
+
stderr: "pipe",
|
|
585
|
+
});
|
|
586
|
+
let timedOut = false;
|
|
587
|
+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
588
|
+
const commandTimeoutMs = agentBrowserProcessTimeoutMs(
|
|
589
|
+
commandArguments,
|
|
590
|
+
defaultTimeoutMs,
|
|
591
|
+
);
|
|
592
|
+
const timeoutTimer = setTimeout(() => {
|
|
593
|
+
timedOut = true;
|
|
594
|
+
command.kill();
|
|
595
|
+
forceKillTimer = setTimeout(() => command.kill(9), 1_000);
|
|
596
|
+
}, commandTimeoutMs);
|
|
597
|
+
let stdout: string;
|
|
598
|
+
let stderr: string;
|
|
599
|
+
let exitCode: number;
|
|
600
|
+
try {
|
|
601
|
+
[stdout, stderr, exitCode] = await Promise.all([
|
|
602
|
+
new Response(command.stdout).text(),
|
|
603
|
+
new Response(command.stderr).text(),
|
|
604
|
+
command.exited,
|
|
605
|
+
]);
|
|
606
|
+
} finally {
|
|
607
|
+
clearTimeout(timeoutTimer);
|
|
608
|
+
if (forceKillTimer !== undefined) clearTimeout(forceKillTimer);
|
|
609
|
+
}
|
|
610
|
+
if (timedOut) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`agent-browser ${renderAgentBrowserCommand(commandArguments)} exceeded its ${commandTimeoutMs}ms process deadline`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (exitCode !== 0) {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`agent-browser ${renderAgentBrowserCommand(commandArguments)} exited with ${exitCode}: ${tail(stderr.trim() || stdout.trim())}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return commandArguments[0] === "batch"
|
|
621
|
+
? parseAgentBrowserBatchEnvelope(stdout)
|
|
622
|
+
: parseAgentBrowserEnvelope(stdout);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function evaluate(expression: string): Promise<unknown> {
|
|
626
|
+
const evaluation = await run(["eval", expression]);
|
|
627
|
+
if (
|
|
628
|
+
typeof evaluation !== "object"
|
|
629
|
+
|| evaluation === null
|
|
630
|
+
|| Array.isArray(evaluation)
|
|
631
|
+
|| !Object.hasOwn(evaluation, "result")
|
|
632
|
+
) {
|
|
633
|
+
throw new Error("browser evaluation returned invalid data");
|
|
634
|
+
}
|
|
635
|
+
return Reflect.get(evaluation, "result");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function readBodyText(): Promise<string> {
|
|
639
|
+
const result = await evaluate("document.body?.innerText ?? ''");
|
|
640
|
+
if (typeof result !== "string") throw new Error("body text evaluation did not return a string");
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function close(): Promise<void> {
|
|
645
|
+
if (!used) return;
|
|
646
|
+
try {
|
|
647
|
+
await run(["close"]);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (!renderUnknown(error).includes("Failed to connect: No such file or directory")) {
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
} finally {
|
|
653
|
+
used = false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function restart(): Promise<void> {
|
|
658
|
+
// A process deadline can leave the old daemon unable to answer `close`.
|
|
659
|
+
// Its verifier-owned idle timeout still bounds that exact namespace, so
|
|
660
|
+
// recovery must rotate even when synchronous cleanup cannot complete.
|
|
661
|
+
try {
|
|
662
|
+
await close();
|
|
663
|
+
} catch {
|
|
664
|
+
used = false;
|
|
665
|
+
}
|
|
666
|
+
environment = createEnvironment();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return { close, evaluate, readBodyText, restart, run };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function collectStream(stream: ReadableStream<Uint8Array>, logLimit: number): Promise<string> {
|
|
673
|
+
const reader = stream.getReader();
|
|
674
|
+
const decoder = new TextDecoder();
|
|
675
|
+
let output = "";
|
|
676
|
+
for (;;) {
|
|
677
|
+
const chunk = await reader.read();
|
|
678
|
+
if (chunk.done) return tail(`${output}${decoder.decode()}`, logLimit);
|
|
679
|
+
output = tail(`${output}${decoder.decode(chunk.value, { stream: true })}`, logLimit);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export function spawnVerificationServer(options: {
|
|
684
|
+
readonly command: readonly string[];
|
|
685
|
+
readonly cwd: string;
|
|
686
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
687
|
+
readonly logLimit?: number;
|
|
688
|
+
}): ManagedVerificationServer {
|
|
689
|
+
const process_ = Bun.spawn([...options.command], {
|
|
690
|
+
cwd: options.cwd,
|
|
691
|
+
env: { ...process.env, ...options.env },
|
|
692
|
+
stdin: "ignore",
|
|
693
|
+
stdout: "pipe",
|
|
694
|
+
stderr: "pipe",
|
|
695
|
+
});
|
|
696
|
+
const logLimit = options.logLimit ?? DEFAULT_LOG_LIMIT;
|
|
697
|
+
const output = Promise.all([
|
|
698
|
+
collectStream(process_.stdout, logLimit),
|
|
699
|
+
collectStream(process_.stderr, logLimit),
|
|
700
|
+
]).then(([stdout, stderr]) => tail(`${stdout}\n${stderr}`.trim(), logLimit));
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
exited: process_.exited,
|
|
704
|
+
exitCode: () => process_.exitCode,
|
|
705
|
+
output,
|
|
706
|
+
terminate: () => process_.kill("SIGTERM"),
|
|
707
|
+
kill: () => process_.kill("SIGKILL"),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export async function runVerificationCommand(options: {
|
|
712
|
+
readonly command: readonly string[];
|
|
713
|
+
readonly cwd: string;
|
|
714
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
715
|
+
readonly label: string;
|
|
716
|
+
readonly timeoutMs: number;
|
|
717
|
+
}): Promise<string> {
|
|
718
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
719
|
+
throw new Error("verification command timeout must be a finite positive duration");
|
|
720
|
+
}
|
|
721
|
+
const command = spawnVerificationServer({
|
|
722
|
+
command: options.command,
|
|
723
|
+
cwd: options.cwd,
|
|
724
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
725
|
+
});
|
|
726
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
727
|
+
const completed = await Promise.race([
|
|
728
|
+
command.exited.then(() => true),
|
|
729
|
+
new Promise<false>((resolve) => {
|
|
730
|
+
timeout = setTimeout(() => resolve(false), options.timeoutMs);
|
|
731
|
+
}),
|
|
732
|
+
]);
|
|
733
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
734
|
+
if (!completed) {
|
|
735
|
+
const output = tail(await stopVerificationServerWithOutput(command));
|
|
736
|
+
const message = `${options.label} exceeded its ${options.timeoutMs}ms deadline`;
|
|
737
|
+
throw new Error(output === "" ? message : `${message}:\n${output}`);
|
|
738
|
+
}
|
|
739
|
+
const exitCode = command.exitCode();
|
|
740
|
+
const output = tail(await stopVerificationServerWithOutput(command));
|
|
741
|
+
if (exitCode !== 0) {
|
|
742
|
+
throw new Error(`${options.label} exited with ${String(exitCode)}:\n${output}`);
|
|
743
|
+
}
|
|
744
|
+
return output;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
type BoundedSettlement<Value> =
|
|
748
|
+
| { readonly settled: false }
|
|
749
|
+
| { readonly settled: true; readonly value: Value };
|
|
750
|
+
|
|
751
|
+
async function settleWithin<Value>(
|
|
752
|
+
promise: Promise<Value>,
|
|
753
|
+
timeoutMs: number,
|
|
754
|
+
): Promise<BoundedSettlement<Value>> {
|
|
755
|
+
return await Promise.race([
|
|
756
|
+
promise.then((value) => ({ settled: true, value }) as const),
|
|
757
|
+
Bun.sleep(timeoutMs).then(() => ({ settled: false }) as const),
|
|
758
|
+
]);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function serverIsReachable(
|
|
762
|
+
baseUrl: string,
|
|
763
|
+
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
764
|
+
readinessPath = "/",
|
|
765
|
+
): Promise<boolean> {
|
|
766
|
+
if (!readinessPath.startsWith("/") || readinessPath.startsWith("//")) {
|
|
767
|
+
throw new Error(`readinessPath must be an origin-relative path, received ${JSON.stringify(readinessPath)}`);
|
|
768
|
+
}
|
|
769
|
+
const probeUrl = new URL(readinessPath, `${normalizeRootHttpOrigin(baseUrl)}/`);
|
|
770
|
+
if (probeUrl.hash !== "") throw new Error("readinessPath cannot contain a fragment");
|
|
771
|
+
try {
|
|
772
|
+
const response = await fetch(probeUrl, {
|
|
773
|
+
signal: AbortSignal.timeout(probeTimeoutMs),
|
|
774
|
+
});
|
|
775
|
+
await response.body?.cancel();
|
|
776
|
+
return response.ok;
|
|
777
|
+
} catch {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function stopVerificationServerWithOutput(
|
|
783
|
+
server: ManagedVerificationServer,
|
|
784
|
+
stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
|
|
785
|
+
): Promise<string> {
|
|
786
|
+
if (!Number.isFinite(stopTimeoutMs) || stopTimeoutMs < 0) {
|
|
787
|
+
throw new Error("verification server stop timeout must be a finite nonnegative duration");
|
|
788
|
+
}
|
|
789
|
+
if (server.exitCode() === null) server.terminate();
|
|
790
|
+
const stopped = await settleWithin(server.exited, stopTimeoutMs);
|
|
791
|
+
if (!stopped.settled) {
|
|
792
|
+
server.kill();
|
|
793
|
+
const killed = await settleWithin(server.exited, stopTimeoutMs);
|
|
794
|
+
if (!killed.settled) {
|
|
795
|
+
throw new Error(
|
|
796
|
+
`verification server did not exit within ${stopTimeoutMs}ms after SIGKILL`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const output = await settleWithin(server.output, stopTimeoutMs);
|
|
801
|
+
if (!output.settled) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`verification server output did not settle within ${stopTimeoutMs}ms after exit`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return output.value;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function stopVerificationServer(
|
|
810
|
+
server: ManagedVerificationServer,
|
|
811
|
+
stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
|
|
812
|
+
): Promise<void> {
|
|
813
|
+
await stopVerificationServerWithOutput(server, stopTimeoutMs);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export async function acquireVerificationServer(options: {
|
|
817
|
+
readonly baseUrl: string;
|
|
818
|
+
readonly label: string;
|
|
819
|
+
readonly localHosts?: ReadonlySet<string>;
|
|
820
|
+
readonly pollIntervalMs?: number;
|
|
821
|
+
readonly probeTimeoutMs?: number;
|
|
822
|
+
readonly reuseProbeIntervalMs?: number;
|
|
823
|
+
readonly reuseExistingLocalServer?: boolean;
|
|
824
|
+
readonly readinessPath?: `/${string}`;
|
|
825
|
+
readonly startServer: () => ManagedVerificationServer;
|
|
826
|
+
readonly startupTimeoutMs: number;
|
|
827
|
+
readonly isReachable?: (
|
|
828
|
+
baseUrl: string,
|
|
829
|
+
probeTimeoutMs: number,
|
|
830
|
+
readinessPath: string,
|
|
831
|
+
) => boolean | Promise<boolean>;
|
|
832
|
+
}): Promise<ServerLease> {
|
|
833
|
+
const probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
834
|
+
const readinessPath = options.readinessPath ?? "/";
|
|
835
|
+
const isReachable = options.isReachable ?? serverIsReachable;
|
|
836
|
+
const canStartLocally = canAutomaticallyStartLocalServer(
|
|
837
|
+
options.baseUrl,
|
|
838
|
+
options.localHosts,
|
|
839
|
+
);
|
|
840
|
+
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
841
|
+
if (canStartLocally && options.reuseExistingLocalServer === false) {
|
|
842
|
+
throw new Error(
|
|
843
|
+
`A local server is already reachable at ${options.baseUrl}; `
|
|
844
|
+
+ "verification will not reuse a server whose worktree ownership is unknown",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
// A verifier-owned command can exit before its child listener has finished
|
|
848
|
+
// shutting down. Require the listener to survive a bounded interval before
|
|
849
|
+
// another verifier trusts it as independently managed infrastructure.
|
|
850
|
+
await Bun.sleep(options.reuseProbeIntervalMs ?? DEFAULT_REUSE_PROBE_INTERVAL_MS);
|
|
851
|
+
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
852
|
+
return { source: "reused" };
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (!canStartLocally) {
|
|
856
|
+
throw new Error(
|
|
857
|
+
`No server is reachable at ${options.baseUrl}; automatic startup is limited to local HTTP URLs`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const server = options.startServer();
|
|
862
|
+
let exitedWithCode: number | null = null;
|
|
863
|
+
try {
|
|
864
|
+
const deadline = Date.now() + options.startupTimeoutMs;
|
|
865
|
+
while (Date.now() < deadline) {
|
|
866
|
+
const exitCode = server.exitCode();
|
|
867
|
+
if (exitCode !== null) {
|
|
868
|
+
exitedWithCode = exitCode;
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
if (await isReachable(options.baseUrl, probeTimeoutMs, readinessPath)) {
|
|
872
|
+
return { source: "started", server };
|
|
873
|
+
}
|
|
874
|
+
await Bun.sleep(options.pollIntervalMs ?? 200);
|
|
875
|
+
}
|
|
876
|
+
} catch (error) {
|
|
877
|
+
await stopVerificationServer(server);
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
880
|
+
if (exitedWithCode !== null) {
|
|
881
|
+
const output = tail(await stopVerificationServerWithOutput(server));
|
|
882
|
+
throw new Error(`${options.label} exited with ${exitedWithCode}:\n${output}`);
|
|
883
|
+
}
|
|
884
|
+
const timeoutMessage = `${options.label} did not become reachable at ${new URL(readinessPath, `${options.baseUrl}/`).href} within ${options.startupTimeoutMs}ms`;
|
|
885
|
+
const output = tail(await stopVerificationServerWithOutput(server));
|
|
886
|
+
throw new Error(output === "" ? timeoutMessage : `${timeoutMessage}:\n${output}`);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export async function createArtifactRun(options: {
|
|
890
|
+
readonly artifactRoot: string;
|
|
891
|
+
readonly generatedAt?: string;
|
|
892
|
+
readonly processId?: number;
|
|
893
|
+
}): Promise<ArtifactRun> {
|
|
894
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
895
|
+
const processId = options.processId ?? process.pid;
|
|
896
|
+
const runId = `${generatedAt.replaceAll(/[^0-9A-Za-z]/gu, "-")}-${processId}`;
|
|
897
|
+
const runDirectory = join(options.artifactRoot, runId);
|
|
898
|
+
await mkdir(runDirectory, { recursive: true });
|
|
899
|
+
return {
|
|
900
|
+
artifactRoot: options.artifactRoot,
|
|
901
|
+
generatedAt,
|
|
902
|
+
manifestPath: join(options.artifactRoot, "manifest.json"),
|
|
903
|
+
runDirectory,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export async function writeJsonAtomically(path: string, value: unknown): Promise<void> {
|
|
908
|
+
const temporaryPath = join(dirname(path), `.${process.pid}-${randomUUID()}.tmp`);
|
|
909
|
+
try {
|
|
910
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
911
|
+
await rename(temporaryPath, path);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
await rm(temporaryPath, { force: true });
|
|
914
|
+
throw error;
|
|
915
|
+
}
|
|
916
|
+
}
|