libretto 0.6.11 → 0.6.12
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 +4 -0
- package/README.template.md +4 -0
- package/dist/cli/cli.js +4 -3
- package/dist/cli/commands/ai.js +3 -2
- package/dist/cli/commands/browser.js +17 -17
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +20 -34
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +81 -9
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/core/ai-model.js +6 -3
- package/dist/cli/core/browser.js +300 -121
- package/dist/cli/core/config.js +4 -2
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +535 -89
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +72 -6
- package/dist/cli/core/experiments.js +66 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/snapshot-analyzer.js +4 -3
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/router.js +4 -1
- package/dist/shared/debug/pause-handler.d.ts +9 -0
- package/dist/shared/debug/pause-handler.js +15 -0
- package/dist/shared/debug/pause.d.ts +1 -2
- package/dist/shared/debug/pause.js +13 -36
- package/dist/shared/ipc/child-process-transport.d.ts +7 -0
- package/dist/shared/ipc/child-process-transport.js +60 -0
- package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/child-process-transport.spec.js +68 -0
- package/dist/shared/ipc/ipc.d.ts +46 -0
- package/dist/shared/ipc/ipc.js +165 -0
- package/dist/shared/ipc/ipc.spec.d.ts +2 -0
- package/dist/shared/ipc/ipc.spec.js +114 -0
- package/dist/shared/ipc/socket-transport.d.ts +9 -0
- package/dist/shared/ipc/socket-transport.js +143 -0
- package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/socket-transport.spec.js +117 -0
- package/dist/shared/package-manager.d.ts +7 -0
- package/dist/shared/package-manager.js +60 -0
- package/dist/shared/paths/paths.d.ts +1 -8
- package/dist/shared/paths/paths.js +1 -49
- package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
- package/dist/shared/snapshot/capture-snapshot.js +463 -0
- package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
- package/dist/shared/snapshot/diff-snapshots.js +358 -0
- package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
- package/dist/shared/snapshot/render-snapshot.js +651 -0
- package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
- package/dist/shared/snapshot/snapshot.spec.js +333 -0
- package/dist/shared/snapshot/types.d.ts +40 -0
- package/dist/shared/snapshot/types.js +0 -0
- package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
- package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +1 -0
- package/docs/experiments.md +67 -0
- package/package.json +4 -2
- package/skills/libretto/SKILL.md +3 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +4 -3
- package/src/cli/commands/ai.ts +3 -2
- package/src/cli/commands/browser.ts +13 -11
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +18 -36
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +99 -11
- package/src/cli/commands/status.ts +5 -4
- package/src/cli/core/ai-model.ts +6 -3
- package/src/cli/core/browser.ts +369 -147
- package/src/cli/core/config.ts +3 -1
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +686 -106
- package/src/cli/core/daemon/ipc.ts +330 -214
- package/src/cli/core/daemon/snapshot.ts +106 -8
- package/src/cli/core/experiments.ts +85 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/snapshot-analyzer.ts +4 -3
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +85 -0
- package/src/cli/router.ts +4 -1
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- package/src/shared/ipc/AGENTS.md +24 -0
- package/src/shared/ipc/child-process-transport.spec.ts +86 -0
- package/src/shared/ipc/child-process-transport.ts +96 -0
- package/src/shared/ipc/ipc.spec.ts +161 -0
- package/src/shared/ipc/ipc.ts +288 -0
- package/src/shared/ipc/socket-transport.spec.ts +141 -0
- package/src/shared/ipc/socket-transport.ts +189 -0
- package/src/shared/package-manager.ts +76 -0
- package/src/shared/paths/paths.ts +0 -72
- package/src/shared/snapshot/capture-snapshot.ts +615 -0
- package/src/shared/snapshot/diff-snapshots.ts +579 -0
- package/src/shared/snapshot/render-snapshot.ts +962 -0
- package/src/shared/snapshot/snapshot.spec.ts +388 -0
- package/src/shared/snapshot/types.ts +43 -0
- package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/daemon/index.js +0 -16
- package/dist/cli/core/daemon/spawn.js +0 -90
- package/dist/cli/core/pause-signals.js +0 -29
- package/dist/cli/workers/run-integration-runtime.js +0 -235
- package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
- package/dist/cli/workers/run-integration-worker.js +0 -64
- package/src/cli/core/daemon/index.ts +0 -24
- package/src/cli/core/daemon/spawn.ts +0 -171
- package/src/cli/core/pause-signals.ts +0 -35
- package/src/cli/workers/run-integration-runtime.ts +0 -326
- package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
- package/src/cli/workers/run-integration-worker.ts +0 -72
package/dist/cli/core/browser.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
chromium
|
|
3
3
|
} from "playwright";
|
|
4
|
-
import { existsSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { createServer } from "node:net";
|
|
7
|
-
import { PROFILES_DIR } from "./context.js";
|
|
7
|
+
import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
|
|
8
8
|
import { readLibrettoConfig } from "./config.js";
|
|
9
|
+
import { librettoCommand } from "../../shared/package-manager.js";
|
|
10
|
+
import { getCloudProviderApi } from "./providers/index.js";
|
|
9
11
|
import {
|
|
10
12
|
assertSessionAvailableForStart,
|
|
11
13
|
clearSessionState,
|
|
@@ -16,9 +18,9 @@ import {
|
|
|
16
18
|
readSessionState,
|
|
17
19
|
writeSessionState
|
|
18
20
|
} from "./session.js";
|
|
19
|
-
import {
|
|
20
|
-
import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
|
|
21
|
+
import { DaemonClient } from "./daemon/ipc.js";
|
|
21
22
|
const CLOSE_WAIT_MS = 1500;
|
|
23
|
+
const PROVIDER_CLOSE_WAIT_MS = 3e4;
|
|
22
24
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
23
25
|
async function pickFreePort() {
|
|
24
26
|
return await new Promise((resolve, reject) => {
|
|
@@ -154,13 +156,13 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
|
|
|
154
156
|
});
|
|
155
157
|
if (state.provider) {
|
|
156
158
|
throw new Error(
|
|
157
|
-
`Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. The remote session may still be active. Try again, or close with:
|
|
159
|
+
`Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`
|
|
158
160
|
);
|
|
159
161
|
}
|
|
160
162
|
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
161
163
|
clearSessionState(session, logger);
|
|
162
164
|
throw new Error(
|
|
163
|
-
`No browser running for session "${session}". Run '
|
|
165
|
+
`No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`
|
|
164
166
|
);
|
|
165
167
|
}
|
|
166
168
|
throw new Error(
|
|
@@ -190,14 +192,14 @@ async function connect(session, logger, timeoutMs = 1e4, options) {
|
|
|
190
192
|
}
|
|
191
193
|
if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
|
|
192
194
|
throw new Error(
|
|
193
|
-
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "
|
|
195
|
+
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`
|
|
194
196
|
);
|
|
195
197
|
}
|
|
196
198
|
const pageRefs = await resolvePageReferences(pages);
|
|
197
199
|
const pageRef = options?.pageId ? pageRefs.find((ref) => ref.id === options.pageId) ?? null : pageRefs[pageRefs.length - 1];
|
|
198
200
|
if (!pageRef) {
|
|
199
201
|
throw new Error(
|
|
200
|
-
`Page "${options?.pageId}" was not found in session "${session}". Run "
|
|
202
|
+
`Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`
|
|
201
203
|
);
|
|
202
204
|
}
|
|
203
205
|
const page = pageRef.page;
|
|
@@ -230,11 +232,15 @@ async function runPages(session, logger) {
|
|
|
230
232
|
let pageSummaries;
|
|
231
233
|
if (!state.daemonSocketPath) {
|
|
232
234
|
throw new Error(
|
|
233
|
-
`Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session:
|
|
235
|
+
`Session "${session}" has no daemon socket. The browser daemon may have crashed. Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
|
-
const client =
|
|
237
|
-
|
|
238
|
+
const client = await DaemonClient.connect(state.daemonSocketPath);
|
|
239
|
+
try {
|
|
240
|
+
pageSummaries = await client.pages();
|
|
241
|
+
} finally {
|
|
242
|
+
client.destroy();
|
|
243
|
+
}
|
|
238
244
|
if (pageSummaries.length === 0) {
|
|
239
245
|
console.log("No pages found.");
|
|
240
246
|
return;
|
|
@@ -299,7 +305,7 @@ async function runOpen(rawUrl, headed, session, logger, options) {
|
|
|
299
305
|
const authProfilePath = getProfilePath(authDomain);
|
|
300
306
|
if (!existsSync(authProfilePath)) {
|
|
301
307
|
throw new Error(
|
|
302
|
-
`No saved auth profile for "${authDomain}". Save one first:
|
|
308
|
+
`No saved auth profile for "${authDomain}". Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`
|
|
303
309
|
);
|
|
304
310
|
}
|
|
305
311
|
}
|
|
@@ -320,25 +326,29 @@ async function runOpen(rawUrl, headed, session, logger, options) {
|
|
|
320
326
|
console.log(`Loading saved profile for ${domain}`);
|
|
321
327
|
}
|
|
322
328
|
console.log(`Launching ${browserMode} browser (session: ${session})...`);
|
|
323
|
-
const { pid, socketPath: daemonSocketPath } = await
|
|
329
|
+
const { pid, socketPath: daemonSocketPath, client } = await DaemonClient.spawn({
|
|
324
330
|
config: {
|
|
325
|
-
port,
|
|
326
|
-
url,
|
|
327
331
|
session,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
+
experiments: options.experiments,
|
|
333
|
+
browser: {
|
|
334
|
+
kind: "launch",
|
|
335
|
+
headed,
|
|
336
|
+
viewport,
|
|
337
|
+
storageStatePath: useProfile ? profilePath : void 0,
|
|
338
|
+
windowPosition,
|
|
339
|
+
remoteDebuggingPort: port,
|
|
340
|
+
initialUrl: url
|
|
341
|
+
}
|
|
332
342
|
},
|
|
333
|
-
session,
|
|
334
343
|
logger,
|
|
335
344
|
logPath: runLogPath,
|
|
336
345
|
// The daemon launches Chromium, installs telemetry, navigates to
|
|
337
346
|
// the URL, and only then starts IPC. Navigation alone can take up
|
|
338
347
|
// to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
|
|
339
348
|
// must cover launch + navigation.
|
|
340
|
-
|
|
349
|
+
startupTimeoutMs: 6e4
|
|
341
350
|
});
|
|
351
|
+
client.destroy();
|
|
342
352
|
writeSessionState(
|
|
343
353
|
{
|
|
344
354
|
port,
|
|
@@ -361,39 +371,50 @@ async function runOpen(rawUrl, headed, session, logger, options) {
|
|
|
361
371
|
});
|
|
362
372
|
console.log(`Browser open (${browserMode}): ${url}`);
|
|
363
373
|
}
|
|
364
|
-
async function runOpenWithProvider(rawUrl, providerName,
|
|
374
|
+
async function runOpenWithProvider(rawUrl, providerName, session, logger, accessMode, experiments) {
|
|
365
375
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
366
376
|
const url = parsedUrl.href;
|
|
367
377
|
logger.info("open-provider-start", { url, provider: providerName, session });
|
|
368
378
|
console.log(
|
|
369
379
|
`Creating ${providerName} browser session (session: ${session})...`
|
|
370
380
|
);
|
|
371
|
-
const providerSession = await provider.createSession();
|
|
372
|
-
logger.info("open-provider-session-created", {
|
|
373
|
-
provider: providerName,
|
|
374
|
-
sessionId: providerSession.sessionId,
|
|
375
|
-
cdpEndpoint: providerSession.cdpEndpoint,
|
|
376
|
-
liveViewUrl: providerSession.liveViewUrl
|
|
377
|
-
});
|
|
378
|
-
if (providerSession.liveViewUrl) {
|
|
379
|
-
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
380
|
-
}
|
|
381
381
|
console.log(`Connecting to ${providerName} browser...`);
|
|
382
382
|
const runLogPath = logFileForSession(session);
|
|
383
|
-
const {
|
|
383
|
+
const {
|
|
384
|
+
pid,
|
|
385
|
+
socketPath: daemonSocketPath,
|
|
386
|
+
provider: providerSession,
|
|
387
|
+
client
|
|
388
|
+
} = await DaemonClient.spawn({
|
|
384
389
|
config: {
|
|
385
|
-
mode: "connect",
|
|
386
390
|
session,
|
|
387
|
-
|
|
388
|
-
|
|
391
|
+
experiments,
|
|
392
|
+
browser: {
|
|
393
|
+
kind: "provider",
|
|
394
|
+
providerName,
|
|
395
|
+
initialUrl: url
|
|
396
|
+
}
|
|
389
397
|
},
|
|
390
|
-
session,
|
|
391
398
|
logger,
|
|
392
399
|
logPath: runLogPath,
|
|
393
400
|
// Remote CDP connection + navigation; must cover both.
|
|
394
|
-
|
|
395
|
-
onFailure: () => provider.closeSession(providerSession.sessionId)
|
|
401
|
+
startupTimeoutMs: 6e4
|
|
396
402
|
});
|
|
403
|
+
client.destroy();
|
|
404
|
+
if (!providerSession) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Provider daemon did not return session metadata for ${providerName}.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
logger.info("open-provider-session-created", {
|
|
410
|
+
provider: providerName,
|
|
411
|
+
sessionId: providerSession.sessionId,
|
|
412
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
413
|
+
liveViewUrl: providerSession.liveViewUrl
|
|
414
|
+
});
|
|
415
|
+
if (providerSession.liveViewUrl) {
|
|
416
|
+
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
417
|
+
}
|
|
397
418
|
writeSessionState(
|
|
398
419
|
{
|
|
399
420
|
port: 0,
|
|
@@ -491,33 +512,60 @@ async function runClose(session, logger) {
|
|
|
491
512
|
console.log(`No browser running for session "${session}".`);
|
|
492
513
|
return;
|
|
493
514
|
}
|
|
494
|
-
|
|
515
|
+
let replayUrl;
|
|
516
|
+
if (state.daemonSocketPath && state.pid != null && isPidRunning(state.pid)) {
|
|
517
|
+
try {
|
|
518
|
+
const result = await closeDaemonSession(
|
|
519
|
+
{
|
|
520
|
+
session,
|
|
521
|
+
pid: state.pid,
|
|
522
|
+
port: state.port,
|
|
523
|
+
provider: state.provider,
|
|
524
|
+
daemonSocketPath: state.daemonSocketPath
|
|
525
|
+
},
|
|
526
|
+
logger
|
|
527
|
+
);
|
|
528
|
+
replayUrl = result.replayUrl;
|
|
529
|
+
if (!state.provider) {
|
|
530
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
531
|
+
}
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (state.provider) {
|
|
534
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
535
|
+
}
|
|
536
|
+
throw formatDaemonCloseFailure(session, state.provider?.name, error);
|
|
537
|
+
}
|
|
538
|
+
} else if (state.pid != null) {
|
|
495
539
|
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
496
540
|
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
497
|
-
|
|
541
|
+
if (state.provider) {
|
|
542
|
+
await waitForProviderCloseResult(session, state.pid);
|
|
543
|
+
} else {
|
|
544
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
545
|
+
}
|
|
498
546
|
}
|
|
499
|
-
let replayUrl;
|
|
500
547
|
if (state.provider) {
|
|
501
|
-
logger.info("close-provider", {
|
|
548
|
+
logger.info("close-provider-daemon-owned", {
|
|
502
549
|
session,
|
|
503
550
|
provider: state.provider.name,
|
|
504
551
|
sessionId: state.provider.sessionId
|
|
505
552
|
});
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
553
|
+
if (!hasProviderCloseResult(session)) {
|
|
554
|
+
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
555
|
+
try {
|
|
556
|
+
replayUrl = await closeProviderSessionDirectly(session, state.provider, logger);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
563
|
+
throw new Error(
|
|
564
|
+
`Failed to confirm remote ${state.provider.name} session cleanup for session "${session}". State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
replayUrl = replayUrl ?? readProviderReplayUrl(session, logger);
|
|
521
569
|
}
|
|
522
570
|
}
|
|
523
571
|
unlinkDaemonSocket(state.daemonSocketPath, logger, session);
|
|
@@ -528,9 +576,115 @@ async function runClose(session, logger) {
|
|
|
528
576
|
console.log(`View recording: ${replayUrl}`);
|
|
529
577
|
}
|
|
530
578
|
}
|
|
579
|
+
async function closeDaemonSession(target, logger) {
|
|
580
|
+
if (!target.daemonSocketPath) {
|
|
581
|
+
throw new Error("session has no daemon socket path");
|
|
582
|
+
}
|
|
583
|
+
const timeoutMs = target.provider ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS;
|
|
584
|
+
logger.info("close-daemon-ipc-start", {
|
|
585
|
+
session: target.session,
|
|
586
|
+
pid: target.pid,
|
|
587
|
+
provider: target.provider?.name,
|
|
588
|
+
timeoutMs
|
|
589
|
+
});
|
|
590
|
+
let client;
|
|
591
|
+
try {
|
|
592
|
+
client = await DaemonClient.connect(target.daemonSocketPath);
|
|
593
|
+
const result = await withTimeout(
|
|
594
|
+
client.close(),
|
|
595
|
+
timeoutMs,
|
|
596
|
+
`Daemon did not respond to close within ${timeoutMs}ms.`
|
|
597
|
+
);
|
|
598
|
+
logger.info("close-daemon-ipc-success", {
|
|
599
|
+
session: target.session,
|
|
600
|
+
replayUrl: result.replayUrl
|
|
601
|
+
});
|
|
602
|
+
return result;
|
|
603
|
+
} finally {
|
|
604
|
+
client?.destroy();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
608
|
+
let timeout;
|
|
609
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
610
|
+
timeout = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
611
|
+
});
|
|
612
|
+
try {
|
|
613
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
614
|
+
} finally {
|
|
615
|
+
if (timeout) clearTimeout(timeout);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function formatDaemonCloseFailure(session, providerName, error) {
|
|
619
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
620
|
+
const cleanupWarning = providerName ? ` State preserved with status "cleanup-failed" because remote ${providerName} cleanup could not be confirmed.` : " State preserved so you can retry or inspect the session.";
|
|
621
|
+
return new Error(
|
|
622
|
+
`Failed to close session "${session}" gracefully over daemon IPC: ${message}.${cleanupWarning} Retry with: ${librettoCommand(`close --session ${session}`)}`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
531
625
|
function waitForCloseSignalWindow(ms) {
|
|
532
626
|
return new Promise((r) => setTimeout(r, ms));
|
|
533
627
|
}
|
|
628
|
+
async function waitForProviderCloseResult(session, pid) {
|
|
629
|
+
const deadline = Date.now() + PROVIDER_CLOSE_WAIT_MS;
|
|
630
|
+
while (Date.now() < deadline) {
|
|
631
|
+
if (hasProviderCloseResult(session) || !isPidRunning(pid)) return;
|
|
632
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function waitForCloseAllTargets(targets) {
|
|
636
|
+
const hasProviderSession = targets.some((target) => target.provider);
|
|
637
|
+
const deadline = Date.now() + (hasProviderSession ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS);
|
|
638
|
+
while (Date.now() < deadline) {
|
|
639
|
+
const stillWaiting = targets.some((target) => {
|
|
640
|
+
if (target.pid == null || !isPidRunning(target.pid)) return false;
|
|
641
|
+
return target.provider ? !hasProviderCloseResult(target.session) : true;
|
|
642
|
+
});
|
|
643
|
+
if (!stillWaiting) return;
|
|
644
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function closeProviderSessionDirectly(session, providerState, logger) {
|
|
648
|
+
try {
|
|
649
|
+
const provider = getCloudProviderApi(providerState.name);
|
|
650
|
+
const result = await provider.closeSession(providerState.sessionId);
|
|
651
|
+
logger.info("close-provider-direct-fallback-success", {
|
|
652
|
+
session,
|
|
653
|
+
provider: providerState.name,
|
|
654
|
+
sessionId: providerState.sessionId,
|
|
655
|
+
replayUrl: result.replayUrl
|
|
656
|
+
});
|
|
657
|
+
return result.replayUrl;
|
|
658
|
+
} catch (error) {
|
|
659
|
+
logger.warn("close-provider-direct-fallback-failed", {
|
|
660
|
+
session,
|
|
661
|
+
provider: providerState.name,
|
|
662
|
+
sessionId: providerState.sessionId,
|
|
663
|
+
error
|
|
664
|
+
});
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Failed to close remote ${providerState.name} session "${providerState.sessionId}" for session "${session}". State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function readProviderReplayUrl(session, logger) {
|
|
671
|
+
const closePath = getSessionProviderClosePath(session);
|
|
672
|
+
if (!existsSync(closePath)) return void 0;
|
|
673
|
+
try {
|
|
674
|
+
const parsed = JSON.parse(readFileSync(closePath, "utf8"));
|
|
675
|
+
return typeof parsed.replayUrl === "string" && parsed.replayUrl.length > 0 ? parsed.replayUrl : void 0;
|
|
676
|
+
} catch (err) {
|
|
677
|
+
logger.warn("provider-close-result-read-failed", {
|
|
678
|
+
session,
|
|
679
|
+
path: closePath,
|
|
680
|
+
error: err
|
|
681
|
+
});
|
|
682
|
+
return void 0;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function hasProviderCloseResult(session) {
|
|
686
|
+
return existsSync(getSessionProviderClosePath(session));
|
|
687
|
+
}
|
|
534
688
|
function sendSignalToProcessGroupOrPid(pid, signal, logger, session) {
|
|
535
689
|
try {
|
|
536
690
|
process.kill(pid, signal);
|
|
@@ -597,6 +751,11 @@ function clearStoppedSessionStates(sessions, logger, skip) {
|
|
|
597
751
|
}
|
|
598
752
|
return cleared;
|
|
599
753
|
}
|
|
754
|
+
function markProviderCleanupFailed(session, logger) {
|
|
755
|
+
const state = readSessionState(session, logger);
|
|
756
|
+
if (!state) return;
|
|
757
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
758
|
+
}
|
|
600
759
|
async function runCloseAll(logger, options) {
|
|
601
760
|
const force = Boolean(options?.force);
|
|
602
761
|
logger.info("close-all-start", { force });
|
|
@@ -611,67 +770,73 @@ async function runCloseAll(logger, options) {
|
|
|
611
770
|
return;
|
|
612
771
|
}
|
|
613
772
|
const failedProviderSessions = /* @__PURE__ */ new Set();
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
773
|
+
const gracefulCloseFailures = /* @__PURE__ */ new Map();
|
|
774
|
+
await Promise.all(
|
|
775
|
+
closable.map(async (target) => {
|
|
776
|
+
if (target.pid == null) return;
|
|
777
|
+
if (target.daemonSocketPath && isPidRunning(target.pid)) {
|
|
778
|
+
try {
|
|
779
|
+
await closeDaemonSession(target, logger);
|
|
780
|
+
return;
|
|
781
|
+
} catch (error) {
|
|
782
|
+
const closeError = formatDaemonCloseFailure(
|
|
783
|
+
target.session,
|
|
784
|
+
target.provider?.name,
|
|
785
|
+
error
|
|
786
|
+
);
|
|
787
|
+
gracefulCloseFailures.set(target.session, closeError);
|
|
788
|
+
logger.warn("close-all-daemon-ipc-failed", {
|
|
627
789
|
session: target.session,
|
|
628
|
-
|
|
790
|
+
pid: target.pid,
|
|
791
|
+
error: closeError.message
|
|
629
792
|
});
|
|
630
|
-
|
|
631
|
-
} catch (err) {
|
|
632
|
-
logger.warn("close-all-provider-error", {
|
|
633
|
-
session: target.session,
|
|
634
|
-
provider: target.provider.name,
|
|
635
|
-
sessionId: target.provider.sessionId,
|
|
636
|
-
error: err
|
|
637
|
-
});
|
|
638
|
-
failedProviderSessions.add(target.session);
|
|
639
|
-
const state = readSessionState(target.session, logger);
|
|
640
|
-
if (state) {
|
|
641
|
-
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
793
|
+
if (!force) return;
|
|
642
794
|
}
|
|
643
795
|
}
|
|
644
|
-
|
|
645
|
-
|
|
796
|
+
logger.info("close-all-sigterm", {
|
|
797
|
+
session: target.session,
|
|
798
|
+
pid: target.pid,
|
|
799
|
+
port: target.port
|
|
800
|
+
});
|
|
801
|
+
sendSignalToProcessGroupOrPid(
|
|
802
|
+
target.pid,
|
|
803
|
+
"SIGTERM",
|
|
804
|
+
logger,
|
|
805
|
+
target.session
|
|
806
|
+
);
|
|
807
|
+
})
|
|
808
|
+
);
|
|
809
|
+
await waitForCloseAllTargets(closable);
|
|
646
810
|
for (const target of closable) {
|
|
647
|
-
if (target.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
"SIGTERM",
|
|
656
|
-
logger,
|
|
657
|
-
target.session
|
|
658
|
-
);
|
|
811
|
+
if (!target.provider || hasProviderCloseResult(target.session)) continue;
|
|
812
|
+
if (target.pid != null && isPidRunning(target.pid)) continue;
|
|
813
|
+
try {
|
|
814
|
+
await closeProviderSessionDirectly(target.session, target.provider, logger);
|
|
815
|
+
} catch {
|
|
816
|
+
markProviderCleanupFailed(target.session, logger);
|
|
817
|
+
failedProviderSessions.add(target.session);
|
|
818
|
+
}
|
|
659
819
|
}
|
|
660
|
-
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
661
820
|
let survivors = closable.filter(
|
|
662
821
|
(target) => target.pid != null && isPidRunning(target.pid)
|
|
663
822
|
);
|
|
664
|
-
if (survivors.length > 0 && !force) {
|
|
823
|
+
if ((survivors.length > 0 || gracefulCloseFailures.size > 0) && !force) {
|
|
665
824
|
const closed = clearStoppedSessionStates(
|
|
666
825
|
closable,
|
|
667
826
|
logger,
|
|
668
827
|
failedProviderSessions
|
|
669
828
|
);
|
|
829
|
+
const failedSessions = Array.from(
|
|
830
|
+
/* @__PURE__ */ new Set([
|
|
831
|
+
...survivors.map((survivor) => survivor.session),
|
|
832
|
+
...gracefulCloseFailures.keys()
|
|
833
|
+
])
|
|
834
|
+
).map((sessionName) => ({ session: sessionName }));
|
|
670
835
|
throw new Error(
|
|
671
836
|
[
|
|
672
|
-
`Failed to close ${
|
|
837
|
+
`Failed to close ${failedSessions.length} session(s) gracefully: ${formatSessionList(failedSessions)}.`,
|
|
673
838
|
`Closed ${closed} session(s).`,
|
|
674
|
-
`Retry with:
|
|
839
|
+
`Retry with: ${librettoCommand("close --all --force")}`
|
|
675
840
|
].join("\n")
|
|
676
841
|
);
|
|
677
842
|
}
|
|
@@ -710,12 +875,11 @@ async function runCloseAll(logger, options) {
|
|
|
710
875
|
);
|
|
711
876
|
}
|
|
712
877
|
}
|
|
878
|
+
const replayUrls = closable.filter((target) => target.provider).flatMap((target) => {
|
|
879
|
+
const replayUrl = readProviderReplayUrl(target.session, logger);
|
|
880
|
+
return replayUrl ? [{ session: target.session, replayUrl }] : [];
|
|
881
|
+
});
|
|
713
882
|
clearStoppedSessionStates(closable, logger, failedProviderSessions);
|
|
714
|
-
if (failedProviderSessions.size > 0) {
|
|
715
|
-
console.log(
|
|
716
|
-
`Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
883
|
if (clearedUnreadableStates > 0) {
|
|
720
884
|
console.log(
|
|
721
885
|
`Cleared ${clearedUnreadableStates} unreadable session state file(s).`
|
|
@@ -723,14 +887,21 @@ async function runCloseAll(logger, options) {
|
|
|
723
887
|
}
|
|
724
888
|
const closedCount = closable.length - failedProviderSessions.size;
|
|
725
889
|
console.log(`Closed ${closedCount} session(s).`);
|
|
890
|
+
if (failedProviderSessions.size > 0) {
|
|
891
|
+
console.warn(
|
|
892
|
+
`Failed to confirm remote cleanup for ${failedProviderSessions.size} provider-backed session(s). State preserved with status "cleanup-failed". Retry with: ${librettoCommand("close --all")}`
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
for (const recording of replayUrls) {
|
|
896
|
+
console.log(
|
|
897
|
+
`View recording for session "${recording.session}": ${recording.replayUrl}`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
726
900
|
if (forceKilled > 0) {
|
|
727
901
|
console.log(`Force-killed ${forceKilled} session(s).`);
|
|
728
902
|
}
|
|
729
|
-
for (const { session, replayUrl } of replayUrls) {
|
|
730
|
-
console.log(`View recording (${session}): ${replayUrl}`);
|
|
731
|
-
}
|
|
732
903
|
}
|
|
733
|
-
async function runConnect(cdpUrl, session, logger, accessMode
|
|
904
|
+
async function runConnect(cdpUrl, session, logger, accessMode, experiments) {
|
|
734
905
|
logger.info("connect-start", { cdpUrl, session, accessMode });
|
|
735
906
|
assertSessionAvailableForStart(session, logger);
|
|
736
907
|
let parsedUrl;
|
|
@@ -742,11 +913,11 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
|
|
|
742
913
|
`Invalid CDP URL: ${cdpUrl}`,
|
|
743
914
|
``,
|
|
744
915
|
`Expected an HTTP or WebSocket URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
|
|
745
|
-
`
|
|
746
|
-
`
|
|
747
|
-
`
|
|
748
|
-
`
|
|
749
|
-
`
|
|
916
|
+
` ${librettoCommand("connect http://127.0.0.1:9222")}`,
|
|
917
|
+
` ${librettoCommand("connect http://remote-host:9222")}`,
|
|
918
|
+
` ${librettoCommand("connect http://remote-host:9222/devtools/browser/<id>")}`,
|
|
919
|
+
` ${librettoCommand("connect ws://remote-host:9222/devtools/browser/<id>")}`,
|
|
920
|
+
` ${librettoCommand("connect wss://remote-host/cdp-endpoint")}`
|
|
750
921
|
].join("\n")
|
|
751
922
|
);
|
|
752
923
|
}
|
|
@@ -775,12 +946,15 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
|
|
|
775
946
|
});
|
|
776
947
|
}
|
|
777
948
|
const runLogPath = logFileForSession(session);
|
|
778
|
-
const { pid, socketPath: daemonSocketPath, client } = await
|
|
779
|
-
config: {
|
|
780
|
-
|
|
949
|
+
const { pid, socketPath: daemonSocketPath, client } = await DaemonClient.spawn({
|
|
950
|
+
config: {
|
|
951
|
+
session,
|
|
952
|
+
experiments,
|
|
953
|
+
browser: { kind: "connect", cdpEndpoint: endpoint }
|
|
954
|
+
},
|
|
781
955
|
logger,
|
|
782
956
|
logPath: runLogPath,
|
|
783
|
-
|
|
957
|
+
startupTimeoutMs: 1e4
|
|
784
958
|
});
|
|
785
959
|
writeSessionState(
|
|
786
960
|
{
|
|
@@ -795,7 +969,12 @@ async function runConnect(cdpUrl, session, logger, accessMode = "write-access")
|
|
|
795
969
|
},
|
|
796
970
|
logger
|
|
797
971
|
);
|
|
798
|
-
|
|
972
|
+
let pages;
|
|
973
|
+
try {
|
|
974
|
+
pages = await client.pages();
|
|
975
|
+
} finally {
|
|
976
|
+
client.destroy();
|
|
977
|
+
}
|
|
799
978
|
logger.info("connect-success", { cdpUrl: endpoint, session, port });
|
|
800
979
|
console.log(`Connected to ${endpoint} (session: ${session})`);
|
|
801
980
|
console.log(` Pages found: ${pages.length}`);
|
package/dist/cli/core/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { SessionAccessModeSchema } from "../../shared/state/index.js";
|
|
5
5
|
import { LIBRETTO_CONFIG_PATH } from "./context.js";
|
|
6
|
+
import { librettoCommand } from "../../shared/package-manager.js";
|
|
6
7
|
const CURRENT_CONFIG_VERSION = 1;
|
|
7
8
|
const ViewportConfigSchema = z.object({
|
|
8
9
|
width: z.number().int().min(1),
|
|
@@ -18,7 +19,8 @@ const LibrettoConfigSchema = z.object({
|
|
|
18
19
|
viewport: ViewportConfigSchema.optional(),
|
|
19
20
|
windowPosition: WindowPositionConfigSchema.optional(),
|
|
20
21
|
provider: z.string().optional(),
|
|
21
|
-
sessionMode: SessionAccessModeSchema.optional()
|
|
22
|
+
sessionMode: SessionAccessModeSchema.optional(),
|
|
23
|
+
experiments: z.record(z.string(), z.boolean()).optional()
|
|
22
24
|
}).passthrough();
|
|
23
25
|
function formatConfigIssues(error) {
|
|
24
26
|
return error.issues.map((issue) => ` - ${issue.path.join(".") || "root"}: ${issue.message}`).join("\n");
|
|
@@ -54,7 +56,7 @@ ${detail}` : null,
|
|
|
54
56
|
' - "snapshotModel", "viewport", "windowPosition", and "sessionMode" are optional.',
|
|
55
57
|
' - "snapshotModel" must be a provider/model string like "openai/gpt-5.4" or "anthropic/claude-sonnet-4-6".',
|
|
56
58
|
"Fix the file to match this shape, or delete it and rerun:",
|
|
57
|
-
`
|
|
59
|
+
` ${librettoCommand("ai configure openai | anthropic | gemini | vertex | openrouter")}`
|
|
58
60
|
].filter(Boolean).join("\n")
|
|
59
61
|
);
|
|
60
62
|
}
|
package/dist/cli/core/context.js
CHANGED
|
@@ -30,6 +30,9 @@ function getSessionNetworkLogPath(session) {
|
|
|
30
30
|
function getSessionActionsLogPath(session) {
|
|
31
31
|
return join(getSessionDir(session), "actions.jsonl");
|
|
32
32
|
}
|
|
33
|
+
function getSessionProviderClosePath(session) {
|
|
34
|
+
return join(getSessionDir(session), "provider-close.json");
|
|
35
|
+
}
|
|
33
36
|
function getSessionSnapshotsDir(session) {
|
|
34
37
|
return join(getSessionDir(session), "snapshots");
|
|
35
38
|
}
|
|
@@ -75,6 +78,7 @@ export {
|
|
|
75
78
|
getSessionDir,
|
|
76
79
|
getSessionLogsPath,
|
|
77
80
|
getSessionNetworkLogPath,
|
|
81
|
+
getSessionProviderClosePath,
|
|
78
82
|
getSessionSnapshotRunDir,
|
|
79
83
|
getSessionSnapshotsDir,
|
|
80
84
|
getSessionStatePath,
|