libretto 0.6.11 → 0.6.13
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 +7 -8
- package/README.template.md +7 -8
- package/dist/cli/cli.js +0 -22
- package/dist/cli/commands/browser.js +18 -24
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +3 -310
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +46 -64
- package/dist/cli/commands/status.js +1 -40
- package/dist/cli/core/browser.js +303 -124
- package/dist/cli/core/config.js +5 -6
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +497 -90
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +48 -9
- package/dist/cli/core/experiments.js +39 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/index.js +0 -2
- package/dist/cli/router.js +4 -3
- 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/instrumentation/instrument.js +4 -4
- 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/docs/releasing.md +8 -6
- package/package.json +5 -2
- package/skills/libretto/SKILL.md +19 -19
- package/skills/libretto/references/configuration-file-reference.md +6 -12
- package/skills/libretto/references/pages-and-page-targeting.md +1 -1
- package/skills/libretto-readonly/SKILL.md +2 -9
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +0 -23
- package/src/cli/commands/browser.ts +14 -18
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +3 -400
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +54 -94
- package/src/cli/commands/status.ts +1 -48
- package/src/cli/core/browser.ts +372 -150
- package/src/cli/core/config.ts +4 -5
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +645 -107
- package/src/cli/core/daemon/ipc.ts +319 -214
- package/src/cli/core/daemon/snapshot.ts +71 -15
- package/src/cli/core/experiments.ts +56 -0
- package/src/cli/core/resolve-model.ts +5 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +86 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/router.ts +4 -3
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- package/src/shared/instrumentation/instrument.ts +4 -4
- 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/commands/ai.js +0 -109
- package/dist/cli/core/ai-model.js +0 -192
- package/dist/cli/core/api-snapshot-analyzer.js +0 -86
- 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/core/snapshot-analyzer.js +0 -666
- 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/scripts/summarize-evals.mjs +0 -135
- package/src/cli/commands/ai.ts +0 -143
- package/src/cli/core/ai-model.ts +0 -298
- package/src/cli/core/api-snapshot-analyzer.ts +0 -110
- 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/core/snapshot-analyzer.ts +0 -855
- 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/src/cli/core/browser.ts
CHANGED
|
@@ -5,13 +5,17 @@ import {
|
|
|
5
5
|
type CDPSession,
|
|
6
6
|
type Page,
|
|
7
7
|
} from "playwright";
|
|
8
|
-
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
10
|
import { dirname, join } from "node:path";
|
|
10
11
|
import { createServer } from "node:net";
|
|
11
12
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
12
13
|
import type { SessionAccessMode } from "../../shared/state/index.js";
|
|
13
|
-
import {
|
|
14
|
+
import type { Experiments } from "./experiments.js";
|
|
15
|
+
import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
|
|
14
16
|
import { readLibrettoConfig } from "./config.js";
|
|
17
|
+
import { librettoCommand } from "../../shared/package-manager.js";
|
|
18
|
+
import { getCloudProviderApi } from "./providers/index.js";
|
|
15
19
|
import {
|
|
16
20
|
assertSessionAvailableForStart,
|
|
17
21
|
clearSessionState,
|
|
@@ -22,13 +26,14 @@ import {
|
|
|
22
26
|
readSessionState,
|
|
23
27
|
writeSessionState,
|
|
24
28
|
} from "./session.js";
|
|
25
|
-
import
|
|
26
|
-
import { getCloudProviderApi } from "./providers/index.js";
|
|
27
|
-
import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
|
|
29
|
+
import { DaemonClient } from "./daemon/ipc.js";
|
|
28
30
|
|
|
29
31
|
const CLOSE_WAIT_MS = 1_500;
|
|
32
|
+
const PROVIDER_CLOSE_WAIT_MS = 30_000;
|
|
30
33
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
31
34
|
|
|
35
|
+
type CloseResult = { replayUrl?: string };
|
|
36
|
+
|
|
32
37
|
async function pickFreePort(): Promise<number> {
|
|
33
38
|
return await new Promise((resolve, reject) => {
|
|
34
39
|
const server = createServer();
|
|
@@ -230,14 +235,14 @@ export async function connect(
|
|
|
230
235
|
if (state.provider) {
|
|
231
236
|
throw new Error(
|
|
232
237
|
`Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. ` +
|
|
233
|
-
`The remote session may still be active. Try again, or close with:
|
|
238
|
+
`The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`,
|
|
234
239
|
);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
238
243
|
clearSessionState(session, logger);
|
|
239
244
|
throw new Error(
|
|
240
|
-
`No browser running for session "${session}". Run '
|
|
245
|
+
`No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`,
|
|
241
246
|
);
|
|
242
247
|
}
|
|
243
248
|
|
|
@@ -273,7 +278,7 @@ export async function connect(
|
|
|
273
278
|
|
|
274
279
|
if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
|
|
275
280
|
throw new Error(
|
|
276
|
-
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "
|
|
281
|
+
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`,
|
|
277
282
|
);
|
|
278
283
|
}
|
|
279
284
|
|
|
@@ -283,7 +288,7 @@ export async function connect(
|
|
|
283
288
|
: pageRefs[pageRefs.length - 1]!;
|
|
284
289
|
if (!pageRef) {
|
|
285
290
|
throw new Error(
|
|
286
|
-
`Page "${options?.pageId}" was not found in session "${session}". Run "
|
|
291
|
+
`Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`,
|
|
287
292
|
);
|
|
288
293
|
}
|
|
289
294
|
const page = pageRef.page;
|
|
@@ -325,11 +330,15 @@ export async function runPages(
|
|
|
325
330
|
if (!state.daemonSocketPath) {
|
|
326
331
|
throw new Error(
|
|
327
332
|
`Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
|
|
328
|
-
`Close and reopen the session:
|
|
333
|
+
`Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`,
|
|
329
334
|
);
|
|
330
335
|
}
|
|
331
|
-
const client =
|
|
332
|
-
|
|
336
|
+
const client = await DaemonClient.connect(state.daemonSocketPath);
|
|
337
|
+
try {
|
|
338
|
+
pageSummaries = await client.pages();
|
|
339
|
+
} finally {
|
|
340
|
+
client.destroy();
|
|
341
|
+
}
|
|
333
342
|
|
|
334
343
|
if (pageSummaries.length === 0) {
|
|
335
344
|
console.log("No pages found.");
|
|
@@ -387,10 +396,11 @@ export async function runOpen(
|
|
|
387
396
|
headed: boolean,
|
|
388
397
|
session: string,
|
|
389
398
|
logger: LoggerApi,
|
|
390
|
-
options
|
|
399
|
+
options: {
|
|
391
400
|
viewport?: { width: number; height: number };
|
|
392
401
|
accessMode?: SessionAccessMode;
|
|
393
402
|
authProfileDomain?: string;
|
|
403
|
+
experiments: Experiments;
|
|
394
404
|
},
|
|
395
405
|
): Promise<void> {
|
|
396
406
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
@@ -423,8 +433,8 @@ export async function runOpen(
|
|
|
423
433
|
if (!existsSync(authProfilePath)) {
|
|
424
434
|
throw new Error(
|
|
425
435
|
`No saved auth profile for "${authDomain}". ` +
|
|
426
|
-
`Save one first:
|
|
427
|
-
`log in, then run:
|
|
436
|
+
`Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, ` +
|
|
437
|
+
`log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`,
|
|
428
438
|
);
|
|
429
439
|
}
|
|
430
440
|
}
|
|
@@ -453,25 +463,30 @@ export async function runOpen(
|
|
|
453
463
|
// Spawn daemon and wait for IPC readiness. The daemon launches
|
|
454
464
|
// Chromium internally — IPC readiness implies the browser is up,
|
|
455
465
|
// so no separate CDP polling is needed.
|
|
456
|
-
const { pid, socketPath: daemonSocketPath } =
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
466
|
+
const { pid, socketPath: daemonSocketPath, client } =
|
|
467
|
+
await DaemonClient.spawn({
|
|
468
|
+
config: {
|
|
469
|
+
session,
|
|
470
|
+
experiments: options.experiments,
|
|
471
|
+
browser: {
|
|
472
|
+
kind: "launch",
|
|
473
|
+
headed,
|
|
474
|
+
viewport,
|
|
475
|
+
storageStatePath: useProfile ? profilePath : undefined,
|
|
476
|
+
windowPosition,
|
|
477
|
+
remoteDebuggingPort: port,
|
|
478
|
+
initialUrl: url,
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
logger,
|
|
482
|
+
logPath: runLogPath,
|
|
483
|
+
// The daemon launches Chromium, installs telemetry, navigates to
|
|
484
|
+
// the URL, and only then starts IPC. Navigation alone can take up
|
|
485
|
+
// to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
|
|
486
|
+
// must cover launch + navigation.
|
|
487
|
+
startupTimeoutMs: 60_000,
|
|
488
|
+
});
|
|
489
|
+
client.destroy();
|
|
475
490
|
|
|
476
491
|
writeSessionState(
|
|
477
492
|
{
|
|
@@ -500,10 +515,10 @@ export async function runOpen(
|
|
|
500
515
|
export async function runOpenWithProvider(
|
|
501
516
|
rawUrl: string,
|
|
502
517
|
providerName: string,
|
|
503
|
-
provider: ProviderApi,
|
|
504
518
|
session: string,
|
|
505
519
|
logger: LoggerApi,
|
|
506
|
-
accessMode: SessionAccessMode
|
|
520
|
+
accessMode: SessionAccessMode,
|
|
521
|
+
experiments: Experiments,
|
|
507
522
|
): Promise<void> {
|
|
508
523
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
509
524
|
const url = parsedUrl.href;
|
|
@@ -513,36 +528,48 @@ export async function runOpenWithProvider(
|
|
|
513
528
|
`Creating ${providerName} browser session (session: ${session})...`,
|
|
514
529
|
);
|
|
515
530
|
|
|
516
|
-
const providerSession = await provider.createSession();
|
|
517
|
-
logger.info("open-provider-session-created", {
|
|
518
|
-
provider: providerName,
|
|
519
|
-
sessionId: providerSession.sessionId,
|
|
520
|
-
cdpEndpoint: providerSession.cdpEndpoint,
|
|
521
|
-
liveViewUrl: providerSession.liveViewUrl,
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
if (providerSession.liveViewUrl) {
|
|
525
|
-
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
531
|
console.log(`Connecting to ${providerName} browser...`);
|
|
529
532
|
|
|
530
533
|
const runLogPath = logFileForSession(session);
|
|
531
|
-
const {
|
|
534
|
+
const {
|
|
535
|
+
pid,
|
|
536
|
+
socketPath: daemonSocketPath,
|
|
537
|
+
provider: providerSession,
|
|
538
|
+
client,
|
|
539
|
+
} = await DaemonClient.spawn({
|
|
532
540
|
config: {
|
|
533
|
-
mode: "connect" as const,
|
|
534
541
|
session,
|
|
535
|
-
|
|
536
|
-
|
|
542
|
+
experiments,
|
|
543
|
+
browser: {
|
|
544
|
+
kind: "provider",
|
|
545
|
+
providerName,
|
|
546
|
+
initialUrl: url,
|
|
547
|
+
},
|
|
537
548
|
},
|
|
538
|
-
session,
|
|
539
549
|
logger,
|
|
540
550
|
logPath: runLogPath,
|
|
541
551
|
// Remote CDP connection + navigation; must cover both.
|
|
542
|
-
|
|
543
|
-
|
|
552
|
+
startupTimeoutMs: 60_000,
|
|
553
|
+
});
|
|
554
|
+
client.destroy();
|
|
555
|
+
|
|
556
|
+
if (!providerSession) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`Provider daemon did not return session metadata for ${providerName}.`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
logger.info("open-provider-session-created", {
|
|
563
|
+
provider: providerName,
|
|
564
|
+
sessionId: providerSession.sessionId,
|
|
565
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
566
|
+
liveViewUrl: providerSession.liveViewUrl,
|
|
544
567
|
});
|
|
545
568
|
|
|
569
|
+
if (providerSession.liveViewUrl) {
|
|
570
|
+
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
546
573
|
writeSessionState(
|
|
547
574
|
{
|
|
548
575
|
port: 0,
|
|
@@ -631,9 +658,8 @@ export async function runSave(
|
|
|
631
658
|
}
|
|
632
659
|
|
|
633
660
|
const state = { cookies, origins };
|
|
634
|
-
|
|
635
|
-
await
|
|
636
|
-
await fs.writeFile(profilePath, JSON.stringify(state, null, 2));
|
|
661
|
+
await mkdir(dirname(profilePath), { recursive: true });
|
|
662
|
+
await writeFile(profilePath, JSON.stringify(state, null, 2));
|
|
637
663
|
|
|
638
664
|
logger.info("save-success", {
|
|
639
665
|
domain,
|
|
@@ -664,39 +690,62 @@ export async function runClose(
|
|
|
664
690
|
return;
|
|
665
691
|
}
|
|
666
692
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
693
|
+
let replayUrl: string | undefined;
|
|
694
|
+
if (state.daemonSocketPath && state.pid != null && isPidRunning(state.pid)) {
|
|
695
|
+
try {
|
|
696
|
+
const result = await closeDaemonSession(
|
|
697
|
+
{
|
|
698
|
+
session,
|
|
699
|
+
pid: state.pid,
|
|
700
|
+
port: state.port,
|
|
701
|
+
provider: state.provider,
|
|
702
|
+
daemonSocketPath: state.daemonSocketPath,
|
|
703
|
+
},
|
|
704
|
+
logger,
|
|
705
|
+
);
|
|
706
|
+
replayUrl = result.replayUrl;
|
|
707
|
+
if (!state.provider) {
|
|
708
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
709
|
+
}
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (state.provider) {
|
|
712
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
713
|
+
}
|
|
714
|
+
throw formatDaemonCloseFailure(session, state.provider?.name, error);
|
|
715
|
+
}
|
|
716
|
+
} else if (state.pid != null) {
|
|
671
717
|
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
672
718
|
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
673
|
-
|
|
719
|
+
if (state.provider) {
|
|
720
|
+
await waitForProviderCloseResult(session, state.pid);
|
|
721
|
+
} else {
|
|
722
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
723
|
+
}
|
|
674
724
|
}
|
|
675
725
|
|
|
676
|
-
// Close provider session if applicable (tears down the remote browser).
|
|
677
|
-
let replayUrl: string | undefined;
|
|
678
726
|
if (state.provider) {
|
|
679
|
-
logger.info("close-provider", {
|
|
727
|
+
logger.info("close-provider-daemon-owned", {
|
|
680
728
|
session,
|
|
681
729
|
provider: state.provider.name,
|
|
682
730
|
sessionId: state.provider.sessionId,
|
|
683
731
|
});
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
732
|
+
if (!hasProviderCloseResult(session)) {
|
|
733
|
+
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
734
|
+
try {
|
|
735
|
+
replayUrl = await closeProviderSessionDirectly(session, state.provider, logger);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
742
|
+
throw new Error(
|
|
743
|
+
`Failed to confirm remote ${state.provider.name} session cleanup for session "${session}". ` +
|
|
744
|
+
`State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
replayUrl = replayUrl ?? readProviderReplayUrl(session, logger);
|
|
700
749
|
}
|
|
701
750
|
}
|
|
702
751
|
|
|
@@ -717,10 +766,154 @@ type ClosableSession = {
|
|
|
717
766
|
daemonSocketPath?: string;
|
|
718
767
|
};
|
|
719
768
|
|
|
769
|
+
async function closeDaemonSession(
|
|
770
|
+
target: ClosableSession,
|
|
771
|
+
logger: LoggerApi,
|
|
772
|
+
): Promise<CloseResult> {
|
|
773
|
+
if (!target.daemonSocketPath) {
|
|
774
|
+
throw new Error("session has no daemon socket path");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const timeoutMs = target.provider ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS;
|
|
778
|
+
logger.info("close-daemon-ipc-start", {
|
|
779
|
+
session: target.session,
|
|
780
|
+
pid: target.pid,
|
|
781
|
+
provider: target.provider?.name,
|
|
782
|
+
timeoutMs,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
let client: DaemonClient | undefined;
|
|
786
|
+
try {
|
|
787
|
+
client = await DaemonClient.connect(target.daemonSocketPath);
|
|
788
|
+
const result = await withTimeout(
|
|
789
|
+
client.close(),
|
|
790
|
+
timeoutMs,
|
|
791
|
+
`Daemon did not respond to close within ${timeoutMs}ms.`,
|
|
792
|
+
);
|
|
793
|
+
logger.info("close-daemon-ipc-success", {
|
|
794
|
+
session: target.session,
|
|
795
|
+
replayUrl: result.replayUrl,
|
|
796
|
+
});
|
|
797
|
+
return result;
|
|
798
|
+
} finally {
|
|
799
|
+
client?.destroy();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function withTimeout<T>(
|
|
804
|
+
promise: Promise<T>,
|
|
805
|
+
timeoutMs: number,
|
|
806
|
+
timeoutMessage: string,
|
|
807
|
+
): Promise<T> {
|
|
808
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
809
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
810
|
+
timeout = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
811
|
+
});
|
|
812
|
+
try {
|
|
813
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
814
|
+
} finally {
|
|
815
|
+
if (timeout) clearTimeout(timeout);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function formatDaemonCloseFailure(
|
|
820
|
+
session: string,
|
|
821
|
+
providerName: string | undefined,
|
|
822
|
+
error: unknown,
|
|
823
|
+
): Error {
|
|
824
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
825
|
+
const cleanupWarning = providerName
|
|
826
|
+
? ` State preserved with status "cleanup-failed" because remote ${providerName} cleanup could not be confirmed.`
|
|
827
|
+
: " State preserved so you can retry or inspect the session.";
|
|
828
|
+
return new Error(
|
|
829
|
+
`Failed to close session "${session}" gracefully over daemon IPC: ${message}.${cleanupWarning} Retry with: ${librettoCommand(`close --session ${session}`)}`,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
720
833
|
function waitForCloseSignalWindow(ms: number): Promise<void> {
|
|
721
834
|
return new Promise((r) => setTimeout(r, ms));
|
|
722
835
|
}
|
|
723
836
|
|
|
837
|
+
async function waitForProviderCloseResult(
|
|
838
|
+
session: string,
|
|
839
|
+
pid: number,
|
|
840
|
+
): Promise<void> {
|
|
841
|
+
const deadline = Date.now() + PROVIDER_CLOSE_WAIT_MS;
|
|
842
|
+
while (Date.now() < deadline) {
|
|
843
|
+
if (hasProviderCloseResult(session) || !isPidRunning(pid)) return;
|
|
844
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function waitForCloseAllTargets(
|
|
849
|
+
targets: ReadonlyArray<ClosableSession>,
|
|
850
|
+
): Promise<void> {
|
|
851
|
+
const hasProviderSession = targets.some((target) => target.provider);
|
|
852
|
+
const deadline =
|
|
853
|
+
Date.now() + (hasProviderSession ? PROVIDER_CLOSE_WAIT_MS : CLOSE_WAIT_MS);
|
|
854
|
+
while (Date.now() < deadline) {
|
|
855
|
+
const stillWaiting = targets.some((target) => {
|
|
856
|
+
if (target.pid == null || !isPidRunning(target.pid)) return false;
|
|
857
|
+
return target.provider ? !hasProviderCloseResult(target.session) : true;
|
|
858
|
+
});
|
|
859
|
+
if (!stillWaiting) return;
|
|
860
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function closeProviderSessionDirectly(
|
|
865
|
+
session: string,
|
|
866
|
+
providerState: { name: string; sessionId: string },
|
|
867
|
+
logger: LoggerApi,
|
|
868
|
+
): Promise<string | undefined> {
|
|
869
|
+
try {
|
|
870
|
+
const provider = getCloudProviderApi(providerState.name);
|
|
871
|
+
const result = await provider.closeSession(providerState.sessionId);
|
|
872
|
+
logger.info("close-provider-direct-fallback-success", {
|
|
873
|
+
session,
|
|
874
|
+
provider: providerState.name,
|
|
875
|
+
sessionId: providerState.sessionId,
|
|
876
|
+
replayUrl: result.replayUrl,
|
|
877
|
+
});
|
|
878
|
+
return result.replayUrl;
|
|
879
|
+
} catch (error) {
|
|
880
|
+
logger.warn("close-provider-direct-fallback-failed", {
|
|
881
|
+
session,
|
|
882
|
+
provider: providerState.name,
|
|
883
|
+
sessionId: providerState.sessionId,
|
|
884
|
+
error,
|
|
885
|
+
});
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Failed to close remote ${providerState.name} session "${providerState.sessionId}" for session "${session}". ` +
|
|
888
|
+
`State preserved with status "cleanup-failed". Retry with: ${librettoCommand(`close --session ${session}`)}`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function readProviderReplayUrl(session: string, logger: LoggerApi): string | undefined {
|
|
894
|
+
const closePath = getSessionProviderClosePath(session);
|
|
895
|
+
if (!existsSync(closePath)) return undefined;
|
|
896
|
+
try {
|
|
897
|
+
const parsed = JSON.parse(readFileSync(closePath, "utf8")) as {
|
|
898
|
+
replayUrl?: unknown;
|
|
899
|
+
};
|
|
900
|
+
return typeof parsed.replayUrl === "string" && parsed.replayUrl.length > 0
|
|
901
|
+
? parsed.replayUrl
|
|
902
|
+
: undefined;
|
|
903
|
+
} catch (err) {
|
|
904
|
+
logger.warn("provider-close-result-read-failed", {
|
|
905
|
+
session,
|
|
906
|
+
path: closePath,
|
|
907
|
+
error: err,
|
|
908
|
+
});
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function hasProviderCloseResult(session: string): boolean {
|
|
914
|
+
return existsSync(getSessionProviderClosePath(session));
|
|
915
|
+
}
|
|
916
|
+
|
|
724
917
|
function sendSignalToProcessGroupOrPid(
|
|
725
918
|
pid: number,
|
|
726
919
|
signal: NodeJS.Signals,
|
|
@@ -811,6 +1004,12 @@ function clearStoppedSessionStates(
|
|
|
811
1004
|
return cleared;
|
|
812
1005
|
}
|
|
813
1006
|
|
|
1007
|
+
function markProviderCleanupFailed(session: string, logger: LoggerApi): void {
|
|
1008
|
+
const state = readSessionState(session, logger);
|
|
1009
|
+
if (!state) return;
|
|
1010
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
814
1013
|
export async function runCloseAll(
|
|
815
1014
|
logger: LoggerApi,
|
|
816
1015
|
options?: { force?: boolean },
|
|
@@ -828,75 +1027,80 @@ export async function runCloseAll(
|
|
|
828
1027
|
return;
|
|
829
1028
|
}
|
|
830
1029
|
|
|
831
|
-
// Close provider sessions via their APIs
|
|
832
1030
|
const failedProviderSessions = new Set<string>();
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1031
|
+
const gracefulCloseFailures = new Map<string, Error>();
|
|
1032
|
+
|
|
1033
|
+
await Promise.all(
|
|
1034
|
+
closable.map(async (target) => {
|
|
1035
|
+
if (target.pid == null) return;
|
|
1036
|
+
if (target.daemonSocketPath && isPidRunning(target.pid)) {
|
|
1037
|
+
try {
|
|
1038
|
+
await closeDaemonSession(target, logger);
|
|
1039
|
+
return;
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
const closeError = formatDaemonCloseFailure(
|
|
1042
|
+
target.session,
|
|
1043
|
+
target.provider?.name,
|
|
1044
|
+
error,
|
|
1045
|
+
);
|
|
1046
|
+
gracefulCloseFailures.set(target.session, closeError);
|
|
1047
|
+
logger.warn("close-all-daemon-ipc-failed", {
|
|
846
1048
|
session: target.session,
|
|
847
|
-
|
|
1049
|
+
pid: target.pid,
|
|
1050
|
+
error: closeError.message,
|
|
848
1051
|
});
|
|
849
|
-
|
|
850
|
-
} catch (err) {
|
|
851
|
-
logger.warn("close-all-provider-error", {
|
|
852
|
-
session: target.session,
|
|
853
|
-
provider: target.provider.name,
|
|
854
|
-
sessionId: target.provider.sessionId,
|
|
855
|
-
error: err,
|
|
856
|
-
});
|
|
857
|
-
failedProviderSessions.add(target.session);
|
|
858
|
-
// Mark as cleanup-failed, preserving provider.sessionId for retry
|
|
859
|
-
const state = readSessionState(target.session, logger);
|
|
860
|
-
if (state) {
|
|
861
|
-
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
1052
|
+
if (!force) return;
|
|
862
1053
|
}
|
|
863
1054
|
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
1055
|
|
|
867
|
-
|
|
1056
|
+
logger.info("close-all-sigterm", {
|
|
1057
|
+
session: target.session,
|
|
1058
|
+
pid: target.pid,
|
|
1059
|
+
port: target.port,
|
|
1060
|
+
});
|
|
1061
|
+
sendSignalToProcessGroupOrPid(
|
|
1062
|
+
target.pid,
|
|
1063
|
+
"SIGTERM",
|
|
1064
|
+
logger,
|
|
1065
|
+
target.session,
|
|
1066
|
+
);
|
|
1067
|
+
}),
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
await waitForCloseAllTargets(closable);
|
|
1071
|
+
|
|
868
1072
|
for (const target of closable) {
|
|
869
|
-
if (target.
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
"SIGTERM",
|
|
878
|
-
logger,
|
|
879
|
-
target.session,
|
|
880
|
-
);
|
|
1073
|
+
if (!target.provider || hasProviderCloseResult(target.session)) continue;
|
|
1074
|
+
if (target.pid != null && isPidRunning(target.pid)) continue;
|
|
1075
|
+
try {
|
|
1076
|
+
await closeProviderSessionDirectly(target.session, target.provider, logger);
|
|
1077
|
+
} catch {
|
|
1078
|
+
markProviderCleanupFailed(target.session, logger);
|
|
1079
|
+
failedProviderSessions.add(target.session);
|
|
1080
|
+
}
|
|
881
1081
|
}
|
|
882
1082
|
|
|
883
|
-
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
884
|
-
|
|
885
1083
|
let survivors = closable.filter(
|
|
886
1084
|
(target) => target.pid != null && isPidRunning(target.pid),
|
|
887
1085
|
);
|
|
888
|
-
if (survivors.length > 0 && !force) {
|
|
1086
|
+
if ((survivors.length > 0 || gracefulCloseFailures.size > 0) && !force) {
|
|
889
1087
|
const closed = clearStoppedSessionStates(
|
|
890
1088
|
closable,
|
|
891
1089
|
logger,
|
|
892
1090
|
failedProviderSessions,
|
|
893
1091
|
);
|
|
1092
|
+
const failedSessions = Array.from(
|
|
1093
|
+
new Set([
|
|
1094
|
+
...survivors.map((survivor) => survivor.session),
|
|
1095
|
+
...gracefulCloseFailures.keys(),
|
|
1096
|
+
]),
|
|
1097
|
+
).map((sessionName) => ({ session: sessionName }));
|
|
894
1098
|
|
|
895
1099
|
throw new Error(
|
|
896
1100
|
[
|
|
897
|
-
`Failed to close ${
|
|
1101
|
+
`Failed to close ${failedSessions.length} session(s) gracefully: ${formatSessionList(failedSessions)}.`,
|
|
898
1102
|
`Closed ${closed} session(s).`,
|
|
899
|
-
`Retry with:
|
|
1103
|
+
`Retry with: ${librettoCommand("close --all --force")}`,
|
|
900
1104
|
].join("\n"),
|
|
901
1105
|
);
|
|
902
1106
|
}
|
|
@@ -937,13 +1141,14 @@ export async function runCloseAll(
|
|
|
937
1141
|
}
|
|
938
1142
|
}
|
|
939
1143
|
|
|
940
|
-
|
|
1144
|
+
const replayUrls = closable
|
|
1145
|
+
.filter((target) => target.provider)
|
|
1146
|
+
.flatMap((target) => {
|
|
1147
|
+
const replayUrl = readProviderReplayUrl(target.session, logger);
|
|
1148
|
+
return replayUrl ? [{ session: target.session, replayUrl }] : [];
|
|
1149
|
+
});
|
|
941
1150
|
|
|
942
|
-
|
|
943
|
-
console.log(
|
|
944
|
-
`Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`,
|
|
945
|
-
);
|
|
946
|
-
}
|
|
1151
|
+
clearStoppedSessionStates(closable, logger, failedProviderSessions);
|
|
947
1152
|
|
|
948
1153
|
if (clearedUnreadableStates > 0) {
|
|
949
1154
|
console.log(
|
|
@@ -952,19 +1157,28 @@ export async function runCloseAll(
|
|
|
952
1157
|
}
|
|
953
1158
|
const closedCount = closable.length - failedProviderSessions.size;
|
|
954
1159
|
console.log(`Closed ${closedCount} session(s).`);
|
|
1160
|
+
if (failedProviderSessions.size > 0) {
|
|
1161
|
+
console.warn(
|
|
1162
|
+
`Failed to confirm remote cleanup for ${failedProviderSessions.size} provider-backed session(s). ` +
|
|
1163
|
+
`State preserved with status "cleanup-failed". Retry with: ${librettoCommand("close --all")}`,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
for (const recording of replayUrls) {
|
|
1167
|
+
console.log(
|
|
1168
|
+
`View recording for session "${recording.session}": ${recording.replayUrl}`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
955
1171
|
if (forceKilled > 0) {
|
|
956
1172
|
console.log(`Force-killed ${forceKilled} session(s).`);
|
|
957
1173
|
}
|
|
958
|
-
for (const { session, replayUrl } of replayUrls) {
|
|
959
|
-
console.log(`View recording (${session}): ${replayUrl}`);
|
|
960
|
-
}
|
|
961
1174
|
}
|
|
962
1175
|
|
|
963
1176
|
export async function runConnect(
|
|
964
1177
|
cdpUrl: string,
|
|
965
1178
|
session: string,
|
|
966
1179
|
logger: LoggerApi,
|
|
967
|
-
accessMode: SessionAccessMode
|
|
1180
|
+
accessMode: SessionAccessMode,
|
|
1181
|
+
experiments: Experiments,
|
|
968
1182
|
): Promise<void> {
|
|
969
1183
|
logger.info("connect-start", { cdpUrl, session, accessMode });
|
|
970
1184
|
assertSessionAvailableForStart(session, logger);
|
|
@@ -978,11 +1192,11 @@ export async function runConnect(
|
|
|
978
1192
|
`Invalid CDP URL: ${cdpUrl}`,
|
|
979
1193
|
``,
|
|
980
1194
|
`Expected an HTTP or WebSocket URL pointing to a Chrome DevTools Protocol endpoint, for example:`,
|
|
981
|
-
`
|
|
982
|
-
`
|
|
983
|
-
`
|
|
984
|
-
`
|
|
985
|
-
`
|
|
1195
|
+
` ${librettoCommand("connect http://127.0.0.1:9222")}`,
|
|
1196
|
+
` ${librettoCommand("connect http://remote-host:9222")}`,
|
|
1197
|
+
` ${librettoCommand("connect http://remote-host:9222/devtools/browser/<id>")}`,
|
|
1198
|
+
` ${librettoCommand("connect ws://remote-host:9222/devtools/browser/<id>")}`,
|
|
1199
|
+
` ${librettoCommand("connect wss://remote-host/cdp-endpoint")}`,
|
|
986
1200
|
].join("\n"),
|
|
987
1201
|
);
|
|
988
1202
|
}
|
|
@@ -1024,12 +1238,15 @@ export async function runConnect(
|
|
|
1024
1238
|
|
|
1025
1239
|
const runLogPath = logFileForSession(session);
|
|
1026
1240
|
const { pid, socketPath: daemonSocketPath, client } =
|
|
1027
|
-
await
|
|
1028
|
-
config: {
|
|
1029
|
-
|
|
1241
|
+
await DaemonClient.spawn({
|
|
1242
|
+
config: {
|
|
1243
|
+
session,
|
|
1244
|
+
experiments,
|
|
1245
|
+
browser: { kind: "connect", cdpEndpoint: endpoint },
|
|
1246
|
+
},
|
|
1030
1247
|
logger,
|
|
1031
1248
|
logPath: runLogPath,
|
|
1032
|
-
|
|
1249
|
+
startupTimeoutMs: 10_000,
|
|
1033
1250
|
});
|
|
1034
1251
|
|
|
1035
1252
|
writeSessionState(
|
|
@@ -1047,7 +1264,12 @@ export async function runConnect(
|
|
|
1047
1264
|
);
|
|
1048
1265
|
|
|
1049
1266
|
// Query the daemon for discovered pages.
|
|
1050
|
-
|
|
1267
|
+
let pages: OpenPageSummary[];
|
|
1268
|
+
try {
|
|
1269
|
+
pages = await client.pages();
|
|
1270
|
+
} finally {
|
|
1271
|
+
client.destroy();
|
|
1272
|
+
}
|
|
1051
1273
|
|
|
1052
1274
|
logger.info("connect-success", { cdpUrl: endpoint, session, port });
|
|
1053
1275
|
console.log(`Connected to ${endpoint} (session: ${session})`);
|