libretto 0.6.12 → 0.6.14
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 +3 -8
- package/README.template.md +3 -8
- package/dist/cli/cli.js +0 -23
- package/dist/cli/commands/auth.js +24 -33
- package/dist/cli/commands/billing.js +3 -5
- package/dist/cli/commands/browser.js +4 -13
- package/dist/cli/commands/deploy.js +54 -45
- package/dist/cli/commands/execution.js +6 -3
- package/dist/cli/commands/experiments.js +1 -1
- package/dist/cli/commands/setup.js +2 -295
- package/dist/cli/commands/shared.js +1 -1
- package/dist/cli/commands/snapshot.js +10 -100
- package/dist/cli/commands/status.js +2 -42
- package/dist/cli/core/auth-fetch.js +11 -6
- package/dist/cli/core/browser.js +13 -8
- package/dist/cli/core/config.js +3 -6
- package/dist/cli/core/daemon/daemon.js +88 -74
- package/dist/cli/core/daemon/exec-repl.js +133 -0
- package/dist/cli/core/daemon/exec.js +6 -21
- package/dist/cli/core/daemon/ipc.js +47 -4
- package/dist/cli/core/daemon/ipc.spec.js +21 -0
- package/dist/cli/core/daemon/snapshot.js +2 -29
- package/dist/cli/core/exec-compiler.js +8 -3
- package/dist/cli/core/experiments.js +1 -28
- package/dist/cli/core/providers/index.js +13 -4
- package/dist/cli/core/providers/libretto-cloud.js +178 -26
- package/dist/cli/index.js +0 -2
- package/dist/cli/router.js +9 -6
- package/dist/shared/instrumentation/instrument.js +4 -4
- package/dist/shared/ipc/socket-transport.d.ts +2 -1
- package/dist/shared/ipc/socket-transport.js +16 -5
- package/dist/shared/ipc/socket-transport.spec.js +5 -0
- package/docs/releasing.md +8 -6
- package/package.json +3 -2
- package/skills/libretto/SKILL.md +49 -47
- package/skills/libretto/references/code-generation-rules.md +6 -0
- package/skills/libretto/references/configuration-file-reference.md +14 -12
- package/skills/libretto/references/pages-and-page-targeting.md +1 -1
- package/skills/libretto/references/site-security-review.md +6 -6
- package/skills/libretto-readonly/SKILL.md +2 -9
- package/src/cli/cli.ts +0 -24
- package/src/cli/commands/auth.ts +24 -33
- package/src/cli/commands/billing.ts +3 -5
- package/src/cli/commands/browser.ts +6 -16
- package/src/cli/commands/deploy.ts +55 -49
- package/src/cli/commands/execution.ts +6 -3
- package/src/cli/commands/experiments.ts +1 -1
- package/src/cli/commands/setup.ts +2 -381
- package/src/cli/commands/shared.ts +1 -1
- package/src/cli/commands/snapshot.ts +9 -137
- package/src/cli/commands/status.ts +2 -50
- package/src/cli/core/auth-fetch.ts +9 -4
- package/src/cli/core/browser.ts +15 -8
- package/src/cli/core/config.ts +3 -6
- package/src/cli/core/daemon/daemon.ts +106 -76
- package/src/cli/core/daemon/exec-repl.ts +189 -0
- package/src/cli/core/daemon/exec.ts +8 -43
- package/src/cli/core/daemon/ipc.spec.ts +27 -0
- package/src/cli/core/daemon/ipc.ts +81 -23
- package/src/cli/core/daemon/snapshot.ts +1 -43
- package/src/cli/core/exec-compiler.ts +8 -3
- package/src/cli/core/experiments.ts +9 -38
- package/src/cli/core/providers/index.ts +17 -4
- package/src/cli/core/providers/libretto-cloud.ts +224 -36
- package/src/cli/core/resolve-model.ts +5 -0
- package/src/cli/core/workflow-runtime.ts +1 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/router.ts +9 -6
- package/src/shared/instrumentation/instrument.ts +4 -4
- package/src/shared/ipc/socket-transport.spec.ts +6 -0
- package/src/shared/ipc/socket-transport.ts +20 -5
- package/dist/cli/commands/ai.js +0 -110
- package/dist/cli/core/ai-model.js +0 -195
- package/dist/cli/core/api-snapshot-analyzer.js +0 -86
- package/dist/cli/core/snapshot-analyzer.js +0 -667
- package/dist/cli/framework/simple-cli.js +0 -880
- package/scripts/summarize-evals.mjs +0 -135
- package/src/cli/commands/ai.ts +0 -144
- package/src/cli/core/ai-model.ts +0 -301
- package/src/cli/core/api-snapshot-analyzer.ts +0 -110
- package/src/cli/core/snapshot-analyzer.ts +0 -856
- package/src/cli/framework/simple-cli.ts +0 -1459
|
@@ -3,6 +3,7 @@ import type { ChildProcess } from "node:child_process";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { openSync, closeSync } from "node:fs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir, userInfo } from "node:os";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { createIpcPeer, type IpcPeer } from "../../../shared/ipc/ipc.js";
|
|
8
9
|
import { connectToIpcSocket } from "../../../shared/ipc/socket-transport.js";
|
|
@@ -25,9 +26,10 @@ export type DaemonExecArgs = {
|
|
|
25
26
|
|
|
26
27
|
export type DaemonReadonlyExecArgs = { code: string; pageId?: string };
|
|
27
28
|
|
|
28
|
-
export type DaemonSnapshotArgs =
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
export type DaemonSnapshotArgs = {
|
|
30
|
+
pageId?: string;
|
|
31
|
+
useCachedSnapshot?: boolean;
|
|
32
|
+
};
|
|
31
33
|
|
|
32
34
|
export type DaemonExecSuccess = {
|
|
33
35
|
result: unknown;
|
|
@@ -35,24 +37,12 @@ export type DaemonExecSuccess = {
|
|
|
35
37
|
snapshotDiff?: SnapshotDiff;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
export type
|
|
39
|
-
pngPath: string;
|
|
40
|
-
htmlPath: string;
|
|
41
|
-
snapshotRunId: string;
|
|
42
|
-
pageUrl: string;
|
|
43
|
-
title: string;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export type DaemonCompactSnapshotResult = {
|
|
40
|
+
export type DaemonSnapshotResult = {
|
|
47
41
|
mode: "compact";
|
|
48
42
|
pngPath: string;
|
|
49
43
|
snapshot: Snapshot;
|
|
50
44
|
};
|
|
51
45
|
|
|
52
|
-
export type DaemonSnapshotResult =
|
|
53
|
-
| DaemonLegacySnapshotResult
|
|
54
|
-
| DaemonCompactSnapshotResult;
|
|
55
|
-
|
|
56
46
|
export type DaemonCloseResult = { replayUrl?: string };
|
|
57
47
|
|
|
58
48
|
export type DaemonCommandResult<T> =
|
|
@@ -116,6 +106,11 @@ export type DaemonStartupErrorMessage = {
|
|
|
116
106
|
message: string;
|
|
117
107
|
};
|
|
118
108
|
|
|
109
|
+
export type DaemonStartupStatusMessage = {
|
|
110
|
+
type: "startup-status";
|
|
111
|
+
message: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
119
114
|
function isDaemonReadyMessage(message: unknown): message is DaemonReadyMessage {
|
|
120
115
|
if (typeof message !== "object" || message === null) return false;
|
|
121
116
|
const candidate = message as { type?: unknown; socketPath?: unknown };
|
|
@@ -132,6 +127,16 @@ function isDaemonStartupErrorMessage(
|
|
|
132
127
|
);
|
|
133
128
|
}
|
|
134
129
|
|
|
130
|
+
function isDaemonStartupStatusMessage(
|
|
131
|
+
message: unknown,
|
|
132
|
+
): message is DaemonStartupStatusMessage {
|
|
133
|
+
if (typeof message !== "object" || message === null) return false;
|
|
134
|
+
const candidate = message as { type?: unknown; message?: unknown };
|
|
135
|
+
return (
|
|
136
|
+
candidate.type === "startup-status" && typeof candidate.message === "string"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
135
140
|
export type DaemonClientSpawnOptions = {
|
|
136
141
|
config: DaemonConfig;
|
|
137
142
|
logger: LoggerApi;
|
|
@@ -153,18 +158,43 @@ export type DaemonClientSpawnResult = {
|
|
|
153
158
|
// ---------------------------------------------------------------------------
|
|
154
159
|
|
|
155
160
|
/**
|
|
156
|
-
* Deterministic
|
|
161
|
+
* Deterministic IPC endpoint for a given session.
|
|
162
|
+
*
|
|
163
|
+
* Unix-like platforms use a socket path in `/tmp` to stay well under the macOS
|
|
164
|
+
* 104-byte Unix socket path limit. Windows uses a named pipe path because
|
|
165
|
+
* filesystem Unix domain socket paths are not portable there.
|
|
157
166
|
*
|
|
158
|
-
* The
|
|
159
|
-
*
|
|
160
|
-
* repos (or sessions within the same repo) never collide.
|
|
167
|
+
* The hash combines `REPO_ROOT`, the session name, and a user key so different
|
|
168
|
+
* repos, sessions, or local users never collide.
|
|
161
169
|
*/
|
|
162
|
-
export function getDaemonSocketPath(
|
|
170
|
+
export function getDaemonSocketPath(
|
|
171
|
+
session: string,
|
|
172
|
+
platform: NodeJS.Platform = process.platform,
|
|
173
|
+
): string {
|
|
174
|
+
const userKey = getDaemonUserKey();
|
|
163
175
|
const hash = createHash("sha256")
|
|
164
|
-
.update(`${REPO_ROOT}:${session}`)
|
|
176
|
+
.update(`${REPO_ROOT}:${session}:${userKey}`)
|
|
165
177
|
.digest("hex")
|
|
166
178
|
.slice(0, 12);
|
|
167
|
-
|
|
179
|
+
|
|
180
|
+
if (platform === "win32") {
|
|
181
|
+
return `\\\\.\\pipe\\libretto-${hash}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return `/tmp/libretto-${userKey}-${hash}.sock`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getDaemonUserKey(): string {
|
|
188
|
+
if (typeof process.getuid === "function") return String(process.getuid());
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const info = userInfo();
|
|
192
|
+
if (info.username) return info.username;
|
|
193
|
+
} catch {
|
|
194
|
+
// Fall back below.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return createHash("sha256").update(homedir()).digest("hex").slice(0, 12);
|
|
168
198
|
}
|
|
169
199
|
|
|
170
200
|
// ---------------------------------------------------------------------------
|
|
@@ -268,6 +298,13 @@ export class DaemonClient {
|
|
|
268
298
|
onExit: (code, signal, ready) => {
|
|
269
299
|
logger.warn("daemon-exit", { code, signal, session, pid, ready });
|
|
270
300
|
},
|
|
301
|
+
onStatus: (message) => {
|
|
302
|
+
logger.info("daemon-startup-status", {
|
|
303
|
+
session,
|
|
304
|
+
message: message.message,
|
|
305
|
+
});
|
|
306
|
+
console.log(message.message);
|
|
307
|
+
},
|
|
271
308
|
}).catch(async (error: unknown) => {
|
|
272
309
|
try {
|
|
273
310
|
process.kill(pid, "SIGTERM");
|
|
@@ -303,6 +340,7 @@ export class DaemonClient {
|
|
|
303
340
|
signal: NodeJS.Signals | null,
|
|
304
341
|
ready: boolean,
|
|
305
342
|
) => void;
|
|
343
|
+
onStatus?: (message: DaemonStartupStatusMessage) => void;
|
|
306
344
|
}): Promise<DaemonReadyMessage> {
|
|
307
345
|
const {
|
|
308
346
|
child,
|
|
@@ -313,6 +351,7 @@ export class DaemonClient {
|
|
|
313
351
|
onReady,
|
|
314
352
|
onSpawnError,
|
|
315
353
|
onExit,
|
|
354
|
+
onStatus,
|
|
316
355
|
} = args;
|
|
317
356
|
|
|
318
357
|
return new Promise<DaemonReadyMessage>((resolve, reject) => {
|
|
@@ -324,6 +363,8 @@ export class DaemonClient {
|
|
|
324
363
|
child.off("message", onMessage);
|
|
325
364
|
child.off("error", onError);
|
|
326
365
|
child.off("exit", onChildExit);
|
|
366
|
+
process.off("SIGINT", onParentSigint);
|
|
367
|
+
process.off("SIGTERM", onParentSigterm);
|
|
327
368
|
};
|
|
328
369
|
|
|
329
370
|
const fail = (error: Error): void => {
|
|
@@ -338,6 +379,10 @@ export class DaemonClient {
|
|
|
338
379
|
fail(new Error(message.message));
|
|
339
380
|
return;
|
|
340
381
|
}
|
|
382
|
+
if (isDaemonStartupStatusMessage(message)) {
|
|
383
|
+
onStatus?.(message);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
341
386
|
if (!isDaemonReadyMessage(message)) return;
|
|
342
387
|
ready = true;
|
|
343
388
|
cleanup();
|
|
@@ -359,9 +404,22 @@ export class DaemonClient {
|
|
|
359
404
|
fail(formatExitError(code, signal));
|
|
360
405
|
};
|
|
361
406
|
|
|
407
|
+
const forwardSignalToChild = (signal: NodeJS.Signals): void => {
|
|
408
|
+
try {
|
|
409
|
+
child.kill(signal);
|
|
410
|
+
} catch {
|
|
411
|
+
// Child may have already exited.
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const onParentSigint = (): void => forwardSignalToChild("SIGINT");
|
|
416
|
+
const onParentSigterm = (): void => forwardSignalToChild("SIGTERM");
|
|
417
|
+
|
|
362
418
|
child.on("message", onMessage);
|
|
363
419
|
child.on("error", onError);
|
|
364
420
|
child.on("exit", onChildExit);
|
|
421
|
+
process.once("SIGINT", onParentSigint);
|
|
422
|
+
process.once("SIGTERM", onParentSigterm);
|
|
365
423
|
});
|
|
366
424
|
}
|
|
367
425
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Page } from "playwright";
|
|
2
|
-
import { mkdirSync
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
3
|
import type { LoggerApi } from "../../../shared/logger/index.js";
|
|
4
4
|
import { getSessionSnapshotRunDir } from "../context.js";
|
|
5
5
|
import {
|
|
@@ -25,48 +25,6 @@ type SnapshotScreenshot = {
|
|
|
25
25
|
title: string;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
export async function handleSnapshot(
|
|
29
|
-
targetPage: Page,
|
|
30
|
-
session: string,
|
|
31
|
-
logger: LoggerApi,
|
|
32
|
-
pageId?: string,
|
|
33
|
-
): Promise<{
|
|
34
|
-
pngPath: string;
|
|
35
|
-
htmlPath: string;
|
|
36
|
-
snapshotRunId: string;
|
|
37
|
-
pageUrl: string;
|
|
38
|
-
title: string;
|
|
39
|
-
}> {
|
|
40
|
-
const screenshot = await captureSnapshotScreenshot(
|
|
41
|
-
targetPage,
|
|
42
|
-
session,
|
|
43
|
-
logger,
|
|
44
|
-
pageId,
|
|
45
|
-
);
|
|
46
|
-
const htmlPath = `${getSessionSnapshotRunDir(
|
|
47
|
-
session,
|
|
48
|
-
screenshot.snapshotRunId,
|
|
49
|
-
)}/page.html`;
|
|
50
|
-
|
|
51
|
-
// Capture HTML content.
|
|
52
|
-
const htmlContent = await targetPage.content();
|
|
53
|
-
writeFileSync(htmlPath, htmlContent);
|
|
54
|
-
|
|
55
|
-
logger.info("screenshot-success", {
|
|
56
|
-
session,
|
|
57
|
-
pageUrl: screenshot.pageUrl,
|
|
58
|
-
title: screenshot.title,
|
|
59
|
-
pngPath: screenshot.pngPath,
|
|
60
|
-
htmlPath,
|
|
61
|
-
snapshotRunId: screenshot.snapshotRunId,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
...screenshot,
|
|
66
|
-
htmlPath,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
28
|
export async function handleCompactSnapshot(
|
|
71
29
|
targetPage: Page,
|
|
72
30
|
session: string,
|
|
@@ -19,6 +19,13 @@ const stripTypeScriptTypes = (
|
|
|
19
19
|
moduleBuiltin as { stripTypeScriptTypes?: StripTypeScriptTypesFn }
|
|
20
20
|
).stripTypeScriptTypes;
|
|
21
21
|
|
|
22
|
+
export function stripTypeScriptExecCode(code: string): string {
|
|
23
|
+
if (!stripTypeScriptTypes) return code;
|
|
24
|
+
return withSuppressedStripTypeScriptWarning(() =>
|
|
25
|
+
stripTypeScriptTypes(code, { mode: "strip" }),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export function withSuppressedStripTypeScriptWarning<T>(action: () => T): T {
|
|
23
30
|
type EmitWarningFn = (...args: unknown[]) => void;
|
|
24
31
|
const mutableProcess = process as unknown as { emitWarning: EmitWarningFn };
|
|
@@ -66,9 +73,7 @@ export function compileTypeScriptExecFunction(
|
|
|
66
73
|
if (!stripTypeScriptTypes) return null;
|
|
67
74
|
|
|
68
75
|
const wrappedSource = `(async function __librettoExec(${helperNames.join(", ")}) {\n${code}\n})`;
|
|
69
|
-
const jsSource =
|
|
70
|
-
stripTypeScriptTypes(wrappedSource, { mode: "strip" }),
|
|
71
|
-
);
|
|
76
|
+
const jsSource = stripTypeScriptExecCode(wrappedSource);
|
|
72
77
|
const createFunction = new Function(
|
|
73
78
|
`return ${jsSource}`,
|
|
74
79
|
) as () => ExecFunction;
|
|
@@ -4,45 +4,16 @@ import {
|
|
|
4
4
|
writeLibrettoConfig,
|
|
5
5
|
} from "./config.js";
|
|
6
6
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"Compared with the skill's documented behavior:",
|
|
16
|
-
" - Run libretto snapshot --session <name> without --objective or --context.",
|
|
17
|
-
" - Snapshot output is a screenshot path plus a compact accessibility tree; it does not use the PNG + HTML + AI analysis path.",
|
|
18
|
-
" - Run libretto snapshot <ref> --session <name> to inspect a subtree from the latest full compact snapshot.",
|
|
19
|
-
" - Run libretto exec normally; after successful mutations, Libretto prints page-change diffs from compact snapshots without AI analysis.",
|
|
20
|
-
" - If a session was already open before enabling the experiment, close and reopen it before relying on this behavior.",
|
|
21
|
-
"",
|
|
22
|
-
"Full compact snapshot:",
|
|
23
|
-
" libretto snapshot --session <name>",
|
|
24
|
-
"",
|
|
25
|
-
"Cached subtree snapshot:",
|
|
26
|
-
" libretto snapshot <ref> --session <name>",
|
|
27
|
-
"",
|
|
28
|
-
"Run an unscoped snapshot before using refs. Subtree snapshots capture a fresh screenshot but reuse the latest cached tree.",
|
|
29
|
-
"",
|
|
30
|
-
"Notes:",
|
|
31
|
-
" - Use ref forms printed in the tree, such as l16. Numeric-suffix aliases such as e16 also match l16.",
|
|
32
|
-
].join("\n"),
|
|
33
|
-
defaultValue: false,
|
|
34
|
-
},
|
|
35
|
-
} as const satisfies Record<
|
|
36
|
-
string,
|
|
37
|
-
{
|
|
38
|
-
title: string;
|
|
39
|
-
oneSentenceDescription: string;
|
|
40
|
-
docs?: string;
|
|
41
|
-
defaultValue: boolean;
|
|
42
|
-
}
|
|
43
|
-
>;
|
|
7
|
+
export type ExperimentMetadata = {
|
|
8
|
+
title: string;
|
|
9
|
+
oneSentenceDescription: string;
|
|
10
|
+
docs?: string;
|
|
11
|
+
defaultValue: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const EXPERIMENTS: Readonly<Record<string, ExperimentMetadata>> = {};
|
|
44
15
|
|
|
45
|
-
export type ExperimentName =
|
|
16
|
+
export type ExperimentName = string;
|
|
46
17
|
export type Experiments = Record<ExperimentName, boolean>;
|
|
47
18
|
|
|
48
19
|
export function isExperimentName(name: string): name is ExperimentName {
|
|
@@ -4,9 +4,16 @@ import { createKernelProvider } from "./kernel.js";
|
|
|
4
4
|
import { createLibrettoCloudProvider } from "./libretto-cloud.js";
|
|
5
5
|
import type { ProviderApi } from "./types.js";
|
|
6
6
|
|
|
7
|
-
const VALID_PROVIDERS = new Set([
|
|
7
|
+
const VALID_PROVIDERS = new Set([
|
|
8
|
+
"local",
|
|
9
|
+
"kernel",
|
|
10
|
+
"browserbase",
|
|
11
|
+
"libretto-cloud",
|
|
12
|
+
] as const);
|
|
8
13
|
export type ProviderName =
|
|
9
14
|
typeof VALID_PROVIDERS extends Set<infer T> ? T : never;
|
|
15
|
+
const DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS = 60_000;
|
|
16
|
+
const LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS = 10 * 60_000;
|
|
10
17
|
|
|
11
18
|
function assertValidProviderName(value: string, source: string): ProviderName {
|
|
12
19
|
if (!VALID_PROVIDERS.has(value as ProviderName)) {
|
|
@@ -50,9 +57,7 @@ export function getCloudProviderApi(name: string): ProviderApi {
|
|
|
50
57
|
case "browserbase":
|
|
51
58
|
return createBrowserbaseProvider();
|
|
52
59
|
case "libretto-cloud":
|
|
53
|
-
console.warn(
|
|
54
|
-
"Note: The libretto-cloud provider is in alpha.",
|
|
55
|
-
);
|
|
60
|
+
console.warn("Note: The libretto-cloud provider is in alpha.");
|
|
56
61
|
return createLibrettoCloudProvider();
|
|
57
62
|
default:
|
|
58
63
|
throw new Error(
|
|
@@ -60,3 +65,11 @@ export function getCloudProviderApi(name: string): ProviderApi {
|
|
|
60
65
|
);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
export function getProviderStartupTimeoutMs(
|
|
70
|
+
providerName: string | undefined,
|
|
71
|
+
): number {
|
|
72
|
+
return providerName === "libretto-cloud"
|
|
73
|
+
? LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS
|
|
74
|
+
: DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS;
|
|
75
|
+
}
|
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveHostedApiUrl } from "../auth-fetch.js";
|
|
2
2
|
import type { ProviderApi } from "./types.js";
|
|
3
3
|
|
|
4
|
+
type CloudSessionResponse = {
|
|
5
|
+
session_id: string;
|
|
6
|
+
status: string;
|
|
7
|
+
cdp_url: string | null;
|
|
8
|
+
live_view_url: string | null;
|
|
9
|
+
recording_url: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
13
|
+
const DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS = 3_600;
|
|
14
|
+
const QUEUE_WAIT_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
|
|
4
16
|
export function createLibrettoCloudProvider(): ProviderApi {
|
|
5
17
|
const apiKey = process.env.LIBRETTO_API_KEY;
|
|
6
18
|
if (!apiKey)
|
|
7
19
|
throw new Error(
|
|
8
20
|
"LIBRETTO_API_KEY is required for the Libretto Cloud provider.",
|
|
9
21
|
);
|
|
10
|
-
const endpoint =
|
|
22
|
+
const endpoint = resolveHostedApiUrl();
|
|
11
23
|
|
|
12
24
|
// The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
|
|
13
25
|
// must be wrapped as { json: ... } and outputs arrive the same way.
|
|
14
26
|
return {
|
|
15
27
|
async createSession() {
|
|
16
|
-
const
|
|
17
|
-
|
|
28
|
+
const browserSessionTimeoutSeconds = readPositiveNumberEnv(
|
|
29
|
+
"LIBRETTO_TIMEOUT_SECONDS",
|
|
30
|
+
DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS,
|
|
18
31
|
);
|
|
19
32
|
const resp = await fetch(`${endpoint}/v1/sessions/create`, {
|
|
20
33
|
method: "POST",
|
|
@@ -23,48 +36,223 @@ export function createLibrettoCloudProvider(): ProviderApi {
|
|
|
23
36
|
"Content-Type": "application/json",
|
|
24
37
|
},
|
|
25
38
|
body: JSON.stringify({
|
|
26
|
-
json: { timeout_seconds:
|
|
39
|
+
json: { timeout_seconds: browserSessionTimeoutSeconds },
|
|
27
40
|
}),
|
|
28
41
|
});
|
|
29
42
|
if (!resp.ok) {
|
|
30
43
|
const body = await resp.text();
|
|
31
|
-
throw new Error(
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
throw new Error(`Libretto Cloud API error (${resp.status}): ${body}`);
|
|
45
|
+
}
|
|
46
|
+
const { json } = (await resp.json()) as { json: CloudSessionResponse };
|
|
47
|
+
const startupCleanup = createStartupSessionCleanup(
|
|
48
|
+
endpoint,
|
|
49
|
+
apiKey,
|
|
50
|
+
json.session_id,
|
|
51
|
+
);
|
|
52
|
+
let readySession: CloudSessionResponse & { cdp_url: string };
|
|
53
|
+
try {
|
|
54
|
+
readySession = await waitForCloudSessionReady({
|
|
55
|
+
endpoint,
|
|
56
|
+
apiKey,
|
|
57
|
+
session: json,
|
|
58
|
+
timeoutMs: QUEUE_WAIT_TIMEOUT_MS,
|
|
59
|
+
isCancelled: startupCleanup.isCancelled,
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (startupCleanup.isCancelled()) {
|
|
63
|
+
await startupCleanup.waitForClose();
|
|
64
|
+
} else {
|
|
65
|
+
await closeCloudSession(endpoint, apiKey, json.session_id).catch(
|
|
66
|
+
() => {},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
startupCleanup.dispose();
|
|
34
72
|
}
|
|
35
|
-
const { json } = (await resp.json()) as {
|
|
36
|
-
json: {
|
|
37
|
-
session_id: string;
|
|
38
|
-
cdp_url: string;
|
|
39
|
-
live_view_url: string | null;
|
|
40
|
-
recording_url: string | null;
|
|
41
|
-
};
|
|
42
|
-
};
|
|
43
73
|
return {
|
|
44
|
-
sessionId:
|
|
45
|
-
cdpEndpoint:
|
|
46
|
-
liveViewUrl:
|
|
74
|
+
sessionId: readySession.session_id,
|
|
75
|
+
cdpEndpoint: readySession.cdp_url,
|
|
76
|
+
liveViewUrl: readySession.live_view_url ?? undefined,
|
|
47
77
|
};
|
|
48
78
|
},
|
|
49
79
|
async closeSession(sessionId) {
|
|
50
|
-
const
|
|
51
|
-
method: "POST",
|
|
52
|
-
headers: {
|
|
53
|
-
"x-api-key": apiKey,
|
|
54
|
-
"Content-Type": "application/json",
|
|
55
|
-
},
|
|
56
|
-
body: JSON.stringify({ json: { session_id: sessionId } }),
|
|
57
|
-
});
|
|
58
|
-
if (!resp.ok) {
|
|
59
|
-
const body = await resp.text();
|
|
60
|
-
throw new Error(
|
|
61
|
-
`Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const { json } = (await resp.json()) as {
|
|
65
|
-
json: { replay_url: string | null };
|
|
66
|
-
};
|
|
80
|
+
const json = await closeCloudSession(endpoint, apiKey, sessionId);
|
|
67
81
|
return { replayUrl: json.replay_url ?? undefined };
|
|
68
82
|
},
|
|
69
83
|
};
|
|
70
84
|
}
|
|
85
|
+
|
|
86
|
+
async function waitForCloudSessionReady(args: {
|
|
87
|
+
endpoint: string;
|
|
88
|
+
apiKey: string;
|
|
89
|
+
session: CloudSessionResponse;
|
|
90
|
+
timeoutMs: number;
|
|
91
|
+
isCancelled?: () => boolean;
|
|
92
|
+
}): Promise<CloudSessionResponse & { cdp_url: string }> {
|
|
93
|
+
let session = args.session;
|
|
94
|
+
if (args.isCancelled?.()) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (session.cdp_url) {
|
|
100
|
+
return { ...session, cdp_url: session.cdp_url };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
sendStartupStatus(
|
|
104
|
+
`Libretto Cloud browser session queued (session: ${session.session_id}). Waiting for browser capacity...`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const pollIntervalMs = readPositiveNumberEnv(
|
|
108
|
+
"LIBRETTO_CLOUD_SESSION_POLL_INTERVAL_MS",
|
|
109
|
+
DEFAULT_POLL_INTERVAL_MS,
|
|
110
|
+
);
|
|
111
|
+
const deadline = Date.now() + args.timeoutMs;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
if (args.isCancelled?.()) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
await sleep(pollIntervalMs);
|
|
120
|
+
if (args.isCancelled?.()) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
session = await getCloudSession(
|
|
126
|
+
args.endpoint,
|
|
127
|
+
args.apiKey,
|
|
128
|
+
session.session_id,
|
|
129
|
+
);
|
|
130
|
+
if (session.cdp_url) {
|
|
131
|
+
sendStartupStatus(
|
|
132
|
+
`Libretto Cloud browser capacity available (session: ${session.session_id}). Connecting...`,
|
|
133
|
+
);
|
|
134
|
+
return { ...session, cdp_url: session.cdp_url };
|
|
135
|
+
}
|
|
136
|
+
if (!["queued", "starting"].includes(session.status)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Libretto Cloud session ${session.session_id} entered status "${session.status}" before a CDP URL was available.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Timed out waiting for Libretto Cloud browser capacity after ${Math.ceil(args.timeoutMs / 1_000)}s (session: ${session.session_id}).`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function getCloudSession(
|
|
149
|
+
endpoint: string,
|
|
150
|
+
apiKey: string,
|
|
151
|
+
sessionId: string,
|
|
152
|
+
): Promise<CloudSessionResponse> {
|
|
153
|
+
const resp = await fetch(`${endpoint}/v1/sessions/get`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
"x-api-key": apiKey,
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify({ json: { session_id: sessionId } }),
|
|
160
|
+
});
|
|
161
|
+
if (!resp.ok) {
|
|
162
|
+
const body = await resp.text();
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Libretto Cloud API error reading session ${sessionId} (${resp.status}): ${body}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const { json } = (await resp.json()) as { json: CloudSessionResponse };
|
|
168
|
+
return json;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function closeCloudSession(
|
|
172
|
+
endpoint: string,
|
|
173
|
+
apiKey: string,
|
|
174
|
+
sessionId: string,
|
|
175
|
+
): Promise<{ replay_url: string | null }> {
|
|
176
|
+
const resp = await fetch(`${endpoint}/v1/sessions/close`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: {
|
|
179
|
+
"x-api-key": apiKey,
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({ json: { session_id: sessionId } }),
|
|
183
|
+
});
|
|
184
|
+
if (!resp.ok) {
|
|
185
|
+
const body = await resp.text();
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const { json } = (await resp.json()) as {
|
|
191
|
+
json: { replay_url: string | null };
|
|
192
|
+
};
|
|
193
|
+
return json;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createStartupSessionCleanup(
|
|
197
|
+
endpoint: string,
|
|
198
|
+
apiKey: string,
|
|
199
|
+
sessionId: string,
|
|
200
|
+
): {
|
|
201
|
+
isCancelled: () => boolean;
|
|
202
|
+
waitForClose: () => Promise<void>;
|
|
203
|
+
dispose: () => void;
|
|
204
|
+
} {
|
|
205
|
+
let cancelled = false;
|
|
206
|
+
let closePromise: Promise<void> | null = null;
|
|
207
|
+
|
|
208
|
+
const requestClose = (reason: string): void => {
|
|
209
|
+
if (cancelled) return;
|
|
210
|
+
cancelled = true;
|
|
211
|
+
sendStartupStatus(
|
|
212
|
+
`Libretto Cloud browser session cancelled (${reason}). Cleaning up queued session...`,
|
|
213
|
+
);
|
|
214
|
+
closePromise = closeCloudSession(endpoint, apiKey, sessionId).then(
|
|
215
|
+
() => {},
|
|
216
|
+
() => {},
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const onDisconnect = (): void => requestClose("parent command disconnected");
|
|
221
|
+
const onSigint = (): void => requestClose("received SIGINT");
|
|
222
|
+
const onSigterm = (): void => requestClose("received SIGTERM");
|
|
223
|
+
|
|
224
|
+
if (typeof process.send === "function") {
|
|
225
|
+
process.once("disconnect", onDisconnect);
|
|
226
|
+
}
|
|
227
|
+
process.once("SIGINT", onSigint);
|
|
228
|
+
process.once("SIGTERM", onSigterm);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
isCancelled: () => cancelled,
|
|
232
|
+
waitForClose: async () => {
|
|
233
|
+
await closePromise;
|
|
234
|
+
},
|
|
235
|
+
dispose: () => {
|
|
236
|
+
process.off("disconnect", onDisconnect);
|
|
237
|
+
process.off("SIGINT", onSigint);
|
|
238
|
+
process.off("SIGTERM", onSigterm);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readPositiveNumberEnv(name: string, fallback: number): number {
|
|
244
|
+
const raw = process.env[name];
|
|
245
|
+
if (!raw) return fallback;
|
|
246
|
+
const parsed = Number(raw);
|
|
247
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sendStartupStatus(message: string): void {
|
|
251
|
+
if (typeof process.send === "function") {
|
|
252
|
+
process.send({ type: "startup-status", message });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sleep(ms: number): Promise<void> {
|
|
257
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
258
|
+
}
|