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/src/cli/core/browser.ts
CHANGED
|
@@ -5,13 +5,16 @@ 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
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { createServer } from "node:net";
|
|
11
11
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
12
12
|
import type { SessionAccessMode } from "../../shared/state/index.js";
|
|
13
|
-
import {
|
|
13
|
+
import type { Experiments } from "./experiments.js";
|
|
14
|
+
import { getSessionProviderClosePath, PROFILES_DIR } from "./context.js";
|
|
14
15
|
import { readLibrettoConfig } from "./config.js";
|
|
16
|
+
import { librettoCommand } from "../../shared/package-manager.js";
|
|
17
|
+
import { getCloudProviderApi } from "./providers/index.js";
|
|
15
18
|
import {
|
|
16
19
|
assertSessionAvailableForStart,
|
|
17
20
|
clearSessionState,
|
|
@@ -22,13 +25,14 @@ import {
|
|
|
22
25
|
readSessionState,
|
|
23
26
|
writeSessionState,
|
|
24
27
|
} from "./session.js";
|
|
25
|
-
import
|
|
26
|
-
import { getCloudProviderApi } from "./providers/index.js";
|
|
27
|
-
import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
|
|
28
|
+
import { DaemonClient } from "./daemon/ipc.js";
|
|
28
29
|
|
|
29
30
|
const CLOSE_WAIT_MS = 1_500;
|
|
31
|
+
const PROVIDER_CLOSE_WAIT_MS = 30_000;
|
|
30
32
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
31
33
|
|
|
34
|
+
type CloseResult = { replayUrl?: string };
|
|
35
|
+
|
|
32
36
|
async function pickFreePort(): Promise<number> {
|
|
33
37
|
return await new Promise((resolve, reject) => {
|
|
34
38
|
const server = createServer();
|
|
@@ -230,14 +234,14 @@ export async function connect(
|
|
|
230
234
|
if (state.provider) {
|
|
231
235
|
throw new Error(
|
|
232
236
|
`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:
|
|
237
|
+
`The remote session may still be active. Try again, or close with: ${librettoCommand(`close --session ${session}`)}`,
|
|
234
238
|
);
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
238
242
|
clearSessionState(session, logger);
|
|
239
243
|
throw new Error(
|
|
240
|
-
`No browser running for session "${session}". Run '
|
|
244
|
+
`No browser running for session "${session}". Run '${librettoCommand(`open <url> --session ${session}`)}' first.`,
|
|
241
245
|
);
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -273,7 +277,7 @@ export async function connect(
|
|
|
273
277
|
|
|
274
278
|
if (options?.requireSinglePage && !options.pageId && pages.length > 1) {
|
|
275
279
|
throw new Error(
|
|
276
|
-
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "
|
|
280
|
+
`Multiple pages are open in session "${session}". Pass --page <id> to target a page (run "${librettoCommand(`pages --session ${session}`)}" to list ids).`,
|
|
277
281
|
);
|
|
278
282
|
}
|
|
279
283
|
|
|
@@ -283,7 +287,7 @@ export async function connect(
|
|
|
283
287
|
: pageRefs[pageRefs.length - 1]!;
|
|
284
288
|
if (!pageRef) {
|
|
285
289
|
throw new Error(
|
|
286
|
-
`Page "${options?.pageId}" was not found in session "${session}". Run "
|
|
290
|
+
`Page "${options?.pageId}" was not found in session "${session}". Run "${librettoCommand(`pages --session ${session}`)}" to list ids.`,
|
|
287
291
|
);
|
|
288
292
|
}
|
|
289
293
|
const page = pageRef.page;
|
|
@@ -325,11 +329,15 @@ export async function runPages(
|
|
|
325
329
|
if (!state.daemonSocketPath) {
|
|
326
330
|
throw new Error(
|
|
327
331
|
`Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
|
|
328
|
-
`Close and reopen the session:
|
|
332
|
+
`Close and reopen the session: ${librettoCommand(`close --session ${session}`)}`,
|
|
329
333
|
);
|
|
330
334
|
}
|
|
331
|
-
const client =
|
|
332
|
-
|
|
335
|
+
const client = await DaemonClient.connect(state.daemonSocketPath);
|
|
336
|
+
try {
|
|
337
|
+
pageSummaries = await client.pages();
|
|
338
|
+
} finally {
|
|
339
|
+
client.destroy();
|
|
340
|
+
}
|
|
333
341
|
|
|
334
342
|
if (pageSummaries.length === 0) {
|
|
335
343
|
console.log("No pages found.");
|
|
@@ -387,10 +395,11 @@ export async function runOpen(
|
|
|
387
395
|
headed: boolean,
|
|
388
396
|
session: string,
|
|
389
397
|
logger: LoggerApi,
|
|
390
|
-
options
|
|
398
|
+
options: {
|
|
391
399
|
viewport?: { width: number; height: number };
|
|
392
400
|
accessMode?: SessionAccessMode;
|
|
393
401
|
authProfileDomain?: string;
|
|
402
|
+
experiments: Experiments;
|
|
394
403
|
},
|
|
395
404
|
): Promise<void> {
|
|
396
405
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
@@ -423,8 +432,8 @@ export async function runOpen(
|
|
|
423
432
|
if (!existsSync(authProfilePath)) {
|
|
424
433
|
throw new Error(
|
|
425
434
|
`No saved auth profile for "${authDomain}". ` +
|
|
426
|
-
`Save one first:
|
|
427
|
-
`log in, then run:
|
|
435
|
+
`Save one first: ${librettoCommand(`open https://${authDomain} --headed --session <name>`)}, ` +
|
|
436
|
+
`log in, then run: ${librettoCommand(`save ${authDomain} --session <name>`)}`,
|
|
428
437
|
);
|
|
429
438
|
}
|
|
430
439
|
}
|
|
@@ -453,25 +462,30 @@ export async function runOpen(
|
|
|
453
462
|
// Spawn daemon and wait for IPC readiness. The daemon launches
|
|
454
463
|
// Chromium internally — IPC readiness implies the browser is up,
|
|
455
464
|
// 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
|
-
|
|
465
|
+
const { pid, socketPath: daemonSocketPath, client } =
|
|
466
|
+
await DaemonClient.spawn({
|
|
467
|
+
config: {
|
|
468
|
+
session,
|
|
469
|
+
experiments: options.experiments,
|
|
470
|
+
browser: {
|
|
471
|
+
kind: "launch",
|
|
472
|
+
headed,
|
|
473
|
+
viewport,
|
|
474
|
+
storageStatePath: useProfile ? profilePath : undefined,
|
|
475
|
+
windowPosition,
|
|
476
|
+
remoteDebuggingPort: port,
|
|
477
|
+
initialUrl: url,
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
logger,
|
|
481
|
+
logPath: runLogPath,
|
|
482
|
+
// The daemon launches Chromium, installs telemetry, navigates to
|
|
483
|
+
// the URL, and only then starts IPC. Navigation alone can take up
|
|
484
|
+
// to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
|
|
485
|
+
// must cover launch + navigation.
|
|
486
|
+
startupTimeoutMs: 60_000,
|
|
487
|
+
});
|
|
488
|
+
client.destroy();
|
|
475
489
|
|
|
476
490
|
writeSessionState(
|
|
477
491
|
{
|
|
@@ -500,10 +514,10 @@ export async function runOpen(
|
|
|
500
514
|
export async function runOpenWithProvider(
|
|
501
515
|
rawUrl: string,
|
|
502
516
|
providerName: string,
|
|
503
|
-
provider: ProviderApi,
|
|
504
517
|
session: string,
|
|
505
518
|
logger: LoggerApi,
|
|
506
|
-
accessMode: SessionAccessMode
|
|
519
|
+
accessMode: SessionAccessMode,
|
|
520
|
+
experiments: Experiments,
|
|
507
521
|
): Promise<void> {
|
|
508
522
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
509
523
|
const url = parsedUrl.href;
|
|
@@ -513,36 +527,48 @@ export async function runOpenWithProvider(
|
|
|
513
527
|
`Creating ${providerName} browser session (session: ${session})...`,
|
|
514
528
|
);
|
|
515
529
|
|
|
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
530
|
console.log(`Connecting to ${providerName} browser...`);
|
|
529
531
|
|
|
530
532
|
const runLogPath = logFileForSession(session);
|
|
531
|
-
const {
|
|
533
|
+
const {
|
|
534
|
+
pid,
|
|
535
|
+
socketPath: daemonSocketPath,
|
|
536
|
+
provider: providerSession,
|
|
537
|
+
client,
|
|
538
|
+
} = await DaemonClient.spawn({
|
|
532
539
|
config: {
|
|
533
|
-
mode: "connect" as const,
|
|
534
540
|
session,
|
|
535
|
-
|
|
536
|
-
|
|
541
|
+
experiments,
|
|
542
|
+
browser: {
|
|
543
|
+
kind: "provider",
|
|
544
|
+
providerName,
|
|
545
|
+
initialUrl: url,
|
|
546
|
+
},
|
|
537
547
|
},
|
|
538
|
-
session,
|
|
539
548
|
logger,
|
|
540
549
|
logPath: runLogPath,
|
|
541
550
|
// Remote CDP connection + navigation; must cover both.
|
|
542
|
-
|
|
543
|
-
|
|
551
|
+
startupTimeoutMs: 60_000,
|
|
552
|
+
});
|
|
553
|
+
client.destroy();
|
|
554
|
+
|
|
555
|
+
if (!providerSession) {
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Provider daemon did not return session metadata for ${providerName}.`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
logger.info("open-provider-session-created", {
|
|
562
|
+
provider: providerName,
|
|
563
|
+
sessionId: providerSession.sessionId,
|
|
564
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
565
|
+
liveViewUrl: providerSession.liveViewUrl,
|
|
544
566
|
});
|
|
545
567
|
|
|
568
|
+
if (providerSession.liveViewUrl) {
|
|
569
|
+
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
546
572
|
writeSessionState(
|
|
547
573
|
{
|
|
548
574
|
port: 0,
|
|
@@ -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})`);
|