libretto 0.6.8 → 0.6.10
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/dist/cli/cli.js +2 -0
- package/dist/cli/commands/auth.js +535 -0
- package/dist/cli/commands/billing.js +74 -0
- package/dist/cli/commands/browser.js +8 -3
- package/dist/cli/commands/deploy.js +2 -7
- package/dist/cli/commands/execution.js +112 -137
- package/dist/cli/commands/snapshot.js +38 -126
- package/dist/cli/core/ai-model.js +0 -3
- package/dist/cli/core/auth-fetch.js +195 -0
- package/dist/cli/core/auth-storage.js +52 -0
- package/dist/cli/core/browser.js +151 -206
- package/dist/cli/core/daemon/config.js +6 -0
- package/dist/cli/core/daemon/daemon.js +298 -0
- package/dist/cli/core/daemon/exec.js +86 -0
- package/dist/cli/core/daemon/index.js +16 -0
- package/dist/cli/core/daemon/ipc.js +171 -0
- package/dist/cli/core/daemon/pages.js +15 -0
- package/dist/cli/core/daemon/snapshot.js +86 -0
- package/dist/cli/core/daemon/spawn.js +90 -0
- package/dist/cli/core/exec-compiler.js +111 -0
- package/dist/cli/core/prompt.js +72 -0
- package/dist/cli/core/providers/browserbase.js +1 -0
- package/dist/cli/core/providers/kernel.js +1 -0
- package/dist/cli/core/providers/libretto-cloud.js +6 -7
- package/dist/cli/core/readonly-exec.js +1 -1
- package/dist/cli/router.js +4 -0
- package/dist/cli/workers/run-integration-runtime.js +0 -5
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +2 -1
- package/docs/browser-automation-approaches.md +435 -0
- package/docs/releasing.md +117 -0
- package/package.json +4 -3
- package/skills/libretto/SKILL.md +14 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +2 -0
- package/src/cli/commands/auth.ts +787 -0
- package/src/cli/commands/billing.ts +133 -0
- package/src/cli/commands/browser.ts +8 -2
- package/src/cli/commands/deploy.ts +2 -7
- package/src/cli/commands/execution.ts +139 -187
- package/src/cli/commands/snapshot.ts +46 -143
- package/src/cli/core/ai-model.ts +4 -5
- package/src/cli/core/auth-fetch.ts +283 -0
- package/src/cli/core/auth-storage.ts +102 -0
- package/src/cli/core/browser.ts +182 -245
- package/src/cli/core/daemon/config.ts +46 -0
- package/src/cli/core/daemon/daemon.ts +429 -0
- package/src/cli/core/daemon/exec.ts +128 -0
- package/src/cli/core/daemon/index.ts +24 -0
- package/src/cli/core/daemon/ipc.ts +294 -0
- package/src/cli/core/daemon/pages.ts +21 -0
- package/src/cli/core/daemon/snapshot.ts +114 -0
- package/src/cli/core/daemon/spawn.ts +171 -0
- package/src/cli/core/exec-compiler.ts +169 -0
- package/src/cli/core/prompt.ts +94 -0
- package/src/cli/core/providers/browserbase.ts +1 -0
- package/src/cli/core/providers/kernel.ts +1 -0
- package/src/cli/core/providers/libretto-cloud.ts +13 -7
- package/src/cli/core/providers/types.ts +12 -1
- package/src/cli/core/readonly-exec.ts +2 -1
- package/src/cli/router.ts +4 -0
- package/src/cli/workers/run-integration-runtime.ts +0 -6
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/browser-daemon.js +0 -122
- package/src/cli/core/browser-daemon.ts +0 -198
package/src/cli/core/browser.ts
CHANGED
|
@@ -5,12 +5,9 @@ import {
|
|
|
5
5
|
type CDPSession,
|
|
6
6
|
type Page,
|
|
7
7
|
} from "playwright";
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import { createRequire } from "node:module";
|
|
12
10
|
import { createServer } from "node:net";
|
|
13
|
-
import { spawn } from "node:child_process";
|
|
14
11
|
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
15
12
|
import type { SessionAccessMode } from "../../shared/state/index.js";
|
|
16
13
|
import { PROFILES_DIR } from "./context.js";
|
|
@@ -27,6 +24,7 @@ import {
|
|
|
27
24
|
} from "./session.js";
|
|
28
25
|
import type { ProviderApi } from "./providers/types.js";
|
|
29
26
|
import { getCloudProviderApi } from "./providers/index.js";
|
|
27
|
+
import { DaemonClient, spawnSessionDaemon } from "./daemon/index.js";
|
|
30
28
|
|
|
31
29
|
const CLOSE_WAIT_MS = 1_500;
|
|
32
30
|
const FORCE_CLOSE_WAIT_MS = 300;
|
|
@@ -202,27 +200,6 @@ async function resolvePageReferences(pages: Page[]): Promise<PageReference[]> {
|
|
|
202
200
|
return refs;
|
|
203
201
|
}
|
|
204
202
|
|
|
205
|
-
export async function listOpenPages(
|
|
206
|
-
session: string,
|
|
207
|
-
logger: LoggerApi,
|
|
208
|
-
): Promise<OpenPageSummary[]> {
|
|
209
|
-
const { browser, page: activePage } = await connect(session, logger);
|
|
210
|
-
try {
|
|
211
|
-
const pages = browser
|
|
212
|
-
.contexts()
|
|
213
|
-
.flatMap((ctx) => ctx.pages())
|
|
214
|
-
.filter(isOperationalPage);
|
|
215
|
-
const pageRefs = await resolvePageReferences(pages);
|
|
216
|
-
return pageRefs.map(({ id, page }) => ({
|
|
217
|
-
id,
|
|
218
|
-
url: page.url(),
|
|
219
|
-
active: page === activePage,
|
|
220
|
-
}));
|
|
221
|
-
} finally {
|
|
222
|
-
disconnectBrowser(browser, logger, session);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
203
|
export async function connect(
|
|
227
204
|
session: string,
|
|
228
205
|
logger: LoggerApi,
|
|
@@ -341,7 +318,18 @@ export async function runPages(
|
|
|
341
318
|
logger: LoggerApi,
|
|
342
319
|
): Promise<void> {
|
|
343
320
|
logger.info("pages-start", { session });
|
|
344
|
-
|
|
321
|
+
|
|
322
|
+
const state = readSessionStateOrThrow(session);
|
|
323
|
+
let pageSummaries: OpenPageSummary[];
|
|
324
|
+
|
|
325
|
+
if (!state.daemonSocketPath) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
|
|
328
|
+
`Close and reopen the session: libretto close --session ${session}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const client = new DaemonClient(state.daemonSocketPath);
|
|
332
|
+
pageSummaries = await client.pages();
|
|
345
333
|
|
|
346
334
|
if (pageSummaries.length === 0) {
|
|
347
335
|
console.log("No pages found.");
|
|
@@ -402,6 +390,7 @@ export async function runOpen(
|
|
|
402
390
|
options?: {
|
|
403
391
|
viewport?: { width: number; height: number };
|
|
404
392
|
accessMode?: SessionAccessMode;
|
|
393
|
+
authProfileDomain?: string;
|
|
405
394
|
},
|
|
406
395
|
): Promise<void> {
|
|
407
396
|
const parsedUrl = normalizeUrl(rawUrl);
|
|
@@ -423,9 +412,26 @@ export async function runOpen(
|
|
|
423
412
|
const runLogPath = logFileForSession(session);
|
|
424
413
|
|
|
425
414
|
const browserMode = headed ? "headed" : "headless";
|
|
415
|
+
|
|
416
|
+
// When --auth-profile is provided, use that domain for profile lookup
|
|
417
|
+
// instead of deriving it from the URL.
|
|
418
|
+
const authDomain = options?.authProfileDomain
|
|
419
|
+
? normalizeDomain(normalizeUrl(options.authProfileDomain))
|
|
420
|
+
: undefined;
|
|
421
|
+
if (authDomain) {
|
|
422
|
+
const authProfilePath = getProfilePath(authDomain);
|
|
423
|
+
if (!existsSync(authProfilePath)) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`No saved auth profile for "${authDomain}". ` +
|
|
426
|
+
`Save one first: libretto open https://${authDomain} --headed --session <name>, ` +
|
|
427
|
+
`log in, then run: libretto save ${authDomain} --session <name>`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
426
432
|
const supportsSavedProfile =
|
|
427
433
|
parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
|
|
428
|
-
const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined;
|
|
434
|
+
const domain = authDomain ?? (supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined);
|
|
429
435
|
const profilePath = domain ? getProfilePath(domain) : undefined;
|
|
430
436
|
const useProfile = domain ? hasProfile(domain) : false;
|
|
431
437
|
|
|
@@ -444,129 +450,51 @@ export async function runOpen(
|
|
|
444
450
|
}
|
|
445
451
|
console.log(`Launching ${browserMode} browser (session: ${session})...`);
|
|
446
452
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
453
|
+
// Spawn daemon and wait for IPC readiness. The daemon launches
|
|
454
|
+
// Chromium internally — IPC readiness implies the browser is up,
|
|
455
|
+
// so no separate CDP polling is needed.
|
|
456
|
+
const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
|
|
457
|
+
config: {
|
|
458
|
+
port,
|
|
459
|
+
url,
|
|
460
|
+
session,
|
|
461
|
+
headed,
|
|
462
|
+
viewport,
|
|
463
|
+
storageStatePath: useProfile ? profilePath : undefined,
|
|
464
|
+
windowPosition,
|
|
465
|
+
},
|
|
455
466
|
session,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
467
|
+
logger,
|
|
468
|
+
logPath: runLogPath,
|
|
469
|
+
// The daemon launches Chromium, installs telemetry, navigates to
|
|
470
|
+
// the URL, and only then starts IPC. Navigation alone can take up
|
|
471
|
+
// to 45s (page.setDefaultNavigationTimeout), so the IPC timeout
|
|
472
|
+
// must cover launch + navigation.
|
|
473
|
+
ipcTimeoutMs: 60_000,
|
|
474
|
+
});
|
|
463
475
|
|
|
464
|
-
|
|
465
|
-
process.execPath,
|
|
466
|
-
["--import", tsxImportPath, daemonEntryPath, JSON.stringify(daemonConfig)],
|
|
476
|
+
writeSessionState(
|
|
467
477
|
{
|
|
468
|
-
|
|
469
|
-
|
|
478
|
+
port,
|
|
479
|
+
pid,
|
|
480
|
+
session,
|
|
481
|
+
startedAt: new Date().toISOString(),
|
|
482
|
+
status: "active",
|
|
483
|
+
mode: accessMode,
|
|
484
|
+
viewport,
|
|
485
|
+
daemonSocketPath,
|
|
470
486
|
},
|
|
487
|
+
logger,
|
|
471
488
|
);
|
|
472
|
-
child.unref();
|
|
473
|
-
closeSync(childStderrFd);
|
|
474
|
-
|
|
475
|
-
logger.info("open-child-spawned", { pid: child.pid, port, session });
|
|
476
|
-
|
|
477
|
-
let childSpawnError: Error | null = null;
|
|
478
|
-
let childEarlyExit: {
|
|
479
|
-
code: number | null;
|
|
480
|
-
signal: NodeJS.Signals | null;
|
|
481
|
-
} | null = null;
|
|
482
|
-
|
|
483
|
-
child.on("error", (err) => {
|
|
484
|
-
childSpawnError = err;
|
|
485
|
-
logger.error("open-child-spawn-error", { error: err, session, port });
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
child.on("exit", (code, signal) => {
|
|
489
|
-
childEarlyExit = { code, signal };
|
|
490
|
-
logger.warn("open-child-exited", {
|
|
491
|
-
code,
|
|
492
|
-
signal,
|
|
493
|
-
session,
|
|
494
|
-
port,
|
|
495
|
-
pid: child.pid,
|
|
496
|
-
});
|
|
497
|
-
});
|
|
498
489
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
for (let i = 0; i < cdpMaxAttempts; i++) {
|
|
504
|
-
const spawnError = childSpawnError as Error | null;
|
|
505
|
-
if (spawnError !== null) {
|
|
506
|
-
const errWithCode = spawnError as Error & { code?: string };
|
|
507
|
-
const hint =
|
|
508
|
-
errWithCode.code === "ENOENT"
|
|
509
|
-
? " Ensure Node.js is available in PATH for child processes."
|
|
510
|
-
: "";
|
|
511
|
-
throw new Error(
|
|
512
|
-
`Failed to launch browser child process: ${spawnError.message}.${hint} Check logs: ${runLogPath}`,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const earlyExit = childEarlyExit as {
|
|
517
|
-
code: number | null;
|
|
518
|
-
signal: NodeJS.Signals | null;
|
|
519
|
-
} | null;
|
|
520
|
-
if (earlyExit !== null) {
|
|
521
|
-
const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
|
|
522
|
-
throw new Error(
|
|
523
|
-
`Browser child process exited before startup (status: ${status}). Check logs: ${runLogPath}`,
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
await new Promise((r) => setTimeout(r, cdpPollIntervalMs));
|
|
528
|
-
const ready = await fetch(`http://127.0.0.1:${port}/json/version`)
|
|
529
|
-
.then(() => true)
|
|
530
|
-
.catch(() => false);
|
|
531
|
-
if (i > 0 && i % 5 === 0) {
|
|
532
|
-
logger.info("open-waiting-for-cdp", { attempt: i, port, session });
|
|
533
|
-
}
|
|
534
|
-
if (ready) {
|
|
535
|
-
writeSessionState(
|
|
536
|
-
{
|
|
537
|
-
port,
|
|
538
|
-
pid: child.pid!,
|
|
539
|
-
session,
|
|
540
|
-
startedAt: new Date().toISOString(),
|
|
541
|
-
status: "active",
|
|
542
|
-
mode: accessMode,
|
|
543
|
-
viewport,
|
|
544
|
-
},
|
|
545
|
-
logger,
|
|
546
|
-
);
|
|
547
|
-
logger.info("open-success", {
|
|
548
|
-
url,
|
|
549
|
-
mode: browserMode,
|
|
550
|
-
session,
|
|
551
|
-
port,
|
|
552
|
-
pid: child.pid,
|
|
553
|
-
});
|
|
554
|
-
console.log(`Browser open (${browserMode}): ${url}`);
|
|
555
|
-
|
|
556
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
logger.error("open-timeout", {
|
|
490
|
+
logger.info("open-success", {
|
|
491
|
+
url,
|
|
492
|
+
mode: browserMode,
|
|
562
493
|
session,
|
|
563
494
|
port,
|
|
564
|
-
pid
|
|
565
|
-
attempts: cdpMaxAttempts,
|
|
495
|
+
pid,
|
|
566
496
|
});
|
|
567
|
-
|
|
568
|
-
`Failed to connect to browser after ${Math.ceil(cdpStartupTimeoutMs / 1000)}s. Check startup logs: ${runLogPath}`,
|
|
569
|
-
);
|
|
497
|
+
console.log(`Browser open (${browserMode}): ${url}`);
|
|
570
498
|
}
|
|
571
499
|
|
|
572
500
|
export async function runOpenWithProvider(
|
|
@@ -590,75 +518,48 @@ export async function runOpenWithProvider(
|
|
|
590
518
|
provider: providerName,
|
|
591
519
|
sessionId: providerSession.sessionId,
|
|
592
520
|
cdpEndpoint: providerSession.cdpEndpoint,
|
|
521
|
+
liveViewUrl: providerSession.liveViewUrl,
|
|
593
522
|
});
|
|
594
523
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
try {
|
|
599
|
-
browser = await tryConnectToCDP(
|
|
600
|
-
providerSession.cdpEndpoint,
|
|
601
|
-
logger,
|
|
602
|
-
30_000,
|
|
603
|
-
);
|
|
604
|
-
if (!browser) {
|
|
605
|
-
throw new Error(
|
|
606
|
-
`Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`,
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const contexts = browser.contexts();
|
|
611
|
-
let page: Page;
|
|
612
|
-
if (contexts.length > 0 && contexts[0].pages().length > 0) {
|
|
613
|
-
page = contexts[0].pages()[0];
|
|
614
|
-
} else {
|
|
615
|
-
const context =
|
|
616
|
-
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
617
|
-
page = await context.newPage();
|
|
618
|
-
}
|
|
524
|
+
if (providerSession.liveViewUrl) {
|
|
525
|
+
console.log(`View live session: ${providerSession.liveViewUrl}`);
|
|
526
|
+
}
|
|
619
527
|
|
|
620
|
-
|
|
621
|
-
logger.info("open-provider-navigated", { url, session });
|
|
528
|
+
console.log(`Connecting to ${providerName} browser...`);
|
|
622
529
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
);
|
|
530
|
+
const runLogPath = logFileForSession(session);
|
|
531
|
+
const { pid, socketPath: daemonSocketPath } = await spawnSessionDaemon({
|
|
532
|
+
config: {
|
|
533
|
+
mode: "connect" as const,
|
|
534
|
+
session,
|
|
535
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
536
|
+
url,
|
|
537
|
+
},
|
|
538
|
+
session,
|
|
539
|
+
logger,
|
|
540
|
+
logPath: runLogPath,
|
|
541
|
+
// Remote CDP connection + navigation; must cover both.
|
|
542
|
+
ipcTimeoutMs: 60_000,
|
|
543
|
+
onFailure: () => provider.closeSession(providerSession.sessionId),
|
|
544
|
+
});
|
|
639
545
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
await provider.closeSession(providerSession.sessionId);
|
|
653
|
-
} catch (cleanupErr) {
|
|
654
|
-
logger.warn("open-provider-cleanup-failed", {
|
|
655
|
-
provider: providerName,
|
|
546
|
+
writeSessionState(
|
|
547
|
+
{
|
|
548
|
+
port: 0,
|
|
549
|
+
pid,
|
|
550
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
551
|
+
session,
|
|
552
|
+
startedAt: new Date().toISOString(),
|
|
553
|
+
status: "active",
|
|
554
|
+
mode: accessMode,
|
|
555
|
+
daemonSocketPath,
|
|
556
|
+
provider: {
|
|
557
|
+
name: providerName,
|
|
656
558
|
sessionId: providerSession.sessionId,
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
}
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
logger,
|
|
562
|
+
);
|
|
662
563
|
|
|
663
564
|
logger.info("open-provider-success", {
|
|
664
565
|
url,
|
|
@@ -763,8 +664,18 @@ export async function runClose(
|
|
|
763
664
|
return;
|
|
764
665
|
}
|
|
765
666
|
|
|
667
|
+
// Kill local daemon process if present (applies to both local and
|
|
668
|
+
// provider sessions — the daemon disconnects without closing the
|
|
669
|
+
// external browser).
|
|
670
|
+
if (state.pid != null) {
|
|
671
|
+
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
672
|
+
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
673
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Close provider session if applicable (tears down the remote browser).
|
|
677
|
+
let replayUrl: string | undefined;
|
|
766
678
|
if (state.provider) {
|
|
767
|
-
// Cloud provider session — close via provider API, no local pid to kill
|
|
768
679
|
logger.info("close-provider", {
|
|
769
680
|
session,
|
|
770
681
|
provider: state.provider.name,
|
|
@@ -772,7 +683,8 @@ export async function runClose(
|
|
|
772
683
|
});
|
|
773
684
|
try {
|
|
774
685
|
const provider = getCloudProviderApi(state.provider.name);
|
|
775
|
-
await provider.closeSession(state.provider.sessionId);
|
|
686
|
+
const result = await provider.closeSession(state.provider.sessionId);
|
|
687
|
+
replayUrl = result.replayUrl;
|
|
776
688
|
} catch (err) {
|
|
777
689
|
logger.warn("close-provider-error", {
|
|
778
690
|
session,
|
|
@@ -780,25 +692,21 @@ export async function runClose(
|
|
|
780
692
|
sessionId: state.provider.sessionId,
|
|
781
693
|
error: err,
|
|
782
694
|
});
|
|
783
|
-
// Preserve state with cleanup-failed status so the user can retry.
|
|
784
|
-
// The provider.sessionId is retained for manual or future cleanup.
|
|
785
695
|
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
786
696
|
throw new Error(
|
|
787
697
|
`Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
|
|
788
698
|
`State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
|
|
789
699
|
);
|
|
790
700
|
}
|
|
791
|
-
} else {
|
|
792
|
-
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
793
|
-
if (state.pid != null) {
|
|
794
|
-
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
795
|
-
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
796
|
-
}
|
|
797
701
|
}
|
|
798
702
|
|
|
703
|
+
unlinkDaemonSocket(state.daemonSocketPath, logger, session);
|
|
799
704
|
clearSessionState(session, logger);
|
|
800
|
-
logger.info("close-success", { session });
|
|
705
|
+
logger.info("close-success", { session, replayUrl });
|
|
801
706
|
console.log(`Browser closed (session: ${session}).`);
|
|
707
|
+
if (replayUrl) {
|
|
708
|
+
console.log(`View recording: ${replayUrl}`);
|
|
709
|
+
}
|
|
802
710
|
}
|
|
803
711
|
|
|
804
712
|
type ClosableSession = {
|
|
@@ -806,6 +714,7 @@ type ClosableSession = {
|
|
|
806
714
|
pid?: number;
|
|
807
715
|
port: number;
|
|
808
716
|
provider?: { name: string; sessionId: string };
|
|
717
|
+
daemonSocketPath?: string;
|
|
809
718
|
};
|
|
810
719
|
|
|
811
720
|
function waitForCloseSignalWindow(ms: number): Promise<void> {
|
|
@@ -859,12 +768,32 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
859
768
|
pid: state.pid,
|
|
860
769
|
port: state.port,
|
|
861
770
|
provider: state.provider,
|
|
771
|
+
daemonSocketPath: state.daemonSocketPath,
|
|
862
772
|
});
|
|
863
773
|
}
|
|
864
774
|
|
|
865
775
|
return { closable, clearedUnreadableStates };
|
|
866
776
|
}
|
|
867
777
|
|
|
778
|
+
function unlinkDaemonSocket(
|
|
779
|
+
socketPath: string | undefined,
|
|
780
|
+
logger: LoggerApi,
|
|
781
|
+
session: string,
|
|
782
|
+
): void {
|
|
783
|
+
if (!socketPath) return;
|
|
784
|
+
try {
|
|
785
|
+
unlinkSync(socketPath);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
788
|
+
logger.warn("close-socket-unlink-failed", {
|
|
789
|
+
session,
|
|
790
|
+
socketPath,
|
|
791
|
+
error: err,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
868
797
|
function clearStoppedSessionStates(
|
|
869
798
|
sessions: ReadonlyArray<ClosableSession>,
|
|
870
799
|
logger: LoggerApi,
|
|
@@ -874,6 +803,7 @@ function clearStoppedSessionStates(
|
|
|
874
803
|
for (const session of sessions) {
|
|
875
804
|
if (skip?.has(session.session)) continue;
|
|
876
805
|
if (session.pid == null || !isPidRunning(session.pid)) {
|
|
806
|
+
unlinkDaemonSocket(session.daemonSocketPath, logger, session.session);
|
|
877
807
|
clearSessionState(session.session, logger);
|
|
878
808
|
cleared += 1;
|
|
879
809
|
}
|
|
@@ -900,6 +830,7 @@ export async function runCloseAll(
|
|
|
900
830
|
|
|
901
831
|
// Close provider sessions via their APIs
|
|
902
832
|
const failedProviderSessions = new Set<string>();
|
|
833
|
+
const replayUrls: Array<{ session: string; replayUrl: string }> = [];
|
|
903
834
|
for (const target of closable) {
|
|
904
835
|
if (target.provider) {
|
|
905
836
|
logger.info("close-all-provider", {
|
|
@@ -909,7 +840,13 @@ export async function runCloseAll(
|
|
|
909
840
|
});
|
|
910
841
|
try {
|
|
911
842
|
const provider = getCloudProviderApi(target.provider.name);
|
|
912
|
-
await provider.closeSession(target.provider.sessionId);
|
|
843
|
+
const result = await provider.closeSession(target.provider.sessionId);
|
|
844
|
+
if (result.replayUrl) {
|
|
845
|
+
replayUrls.push({
|
|
846
|
+
session: target.session,
|
|
847
|
+
replayUrl: result.replayUrl,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
913
850
|
} catch (err) {
|
|
914
851
|
logger.warn("close-all-provider-error", {
|
|
915
852
|
session: target.session,
|
|
@@ -927,22 +864,20 @@ export async function runCloseAll(
|
|
|
927
864
|
}
|
|
928
865
|
}
|
|
929
866
|
|
|
930
|
-
// Send SIGTERM to local sessions
|
|
867
|
+
// Send SIGTERM to all daemon processes (both local and provider sessions).
|
|
931
868
|
for (const target of closable) {
|
|
932
|
-
if (target.
|
|
869
|
+
if (target.pid == null) continue;
|
|
933
870
|
logger.info("close-all-sigterm", {
|
|
934
871
|
session: target.session,
|
|
935
872
|
pid: target.pid,
|
|
936
873
|
port: target.port,
|
|
937
874
|
});
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
);
|
|
945
|
-
}
|
|
875
|
+
sendSignalToProcessGroupOrPid(
|
|
876
|
+
target.pid,
|
|
877
|
+
"SIGTERM",
|
|
878
|
+
logger,
|
|
879
|
+
target.session,
|
|
880
|
+
);
|
|
946
881
|
}
|
|
947
882
|
|
|
948
883
|
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
@@ -1020,6 +955,9 @@ export async function runCloseAll(
|
|
|
1020
955
|
if (forceKilled > 0) {
|
|
1021
956
|
console.log(`Force-killed ${forceKilled} session(s).`);
|
|
1022
957
|
}
|
|
958
|
+
for (const { session, replayUrl } of replayUrls) {
|
|
959
|
+
console.log(`View recording (${session}): ${replayUrl}`);
|
|
960
|
+
}
|
|
1023
961
|
}
|
|
1024
962
|
|
|
1025
963
|
export async function runConnect(
|
|
@@ -1062,8 +1000,9 @@ export async function runConnect(
|
|
|
1062
1000
|
`Connecting to CDP endpoint at ${endpoint} (session: ${session})...`,
|
|
1063
1001
|
);
|
|
1064
1002
|
|
|
1065
|
-
//
|
|
1066
|
-
// endpoints are validated by the
|
|
1003
|
+
// Fast-fail: verify the CDP endpoint is reachable before spawning
|
|
1004
|
+
// the daemon (HTTP only — WebSocket endpoints are validated by the
|
|
1005
|
+
// daemon's connectOverCDP call).
|
|
1067
1006
|
if (!isWebSocket) {
|
|
1068
1007
|
const versionUrl = `${parsedUrl.protocol}//${parsedUrl.host}/json/version`;
|
|
1069
1008
|
try {
|
|
@@ -1083,41 +1022,39 @@ export async function runConnect(
|
|
|
1083
1022
|
});
|
|
1084
1023
|
}
|
|
1085
1024
|
|
|
1086
|
-
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
logger.info("connect-pages", {
|
|
1096
|
-
session,
|
|
1097
|
-
pageCount: pages.length,
|
|
1098
|
-
urls: pages.map((p) => p.url()),
|
|
1099
|
-
});
|
|
1100
|
-
|
|
1101
|
-
disconnectBrowser(browser, logger, session);
|
|
1025
|
+
const runLogPath = logFileForSession(session);
|
|
1026
|
+
const { pid, socketPath: daemonSocketPath, client } =
|
|
1027
|
+
await spawnSessionDaemon({
|
|
1028
|
+
config: { mode: "connect" as const, session, cdpEndpoint: endpoint },
|
|
1029
|
+
session,
|
|
1030
|
+
logger,
|
|
1031
|
+
logPath: runLogPath,
|
|
1032
|
+
ipcTimeoutMs: 10_000,
|
|
1033
|
+
});
|
|
1102
1034
|
|
|
1103
1035
|
writeSessionState(
|
|
1104
1036
|
{
|
|
1105
1037
|
port,
|
|
1038
|
+
pid,
|
|
1106
1039
|
cdpEndpoint: endpoint,
|
|
1107
1040
|
session,
|
|
1108
1041
|
startedAt: new Date().toISOString(),
|
|
1109
1042
|
status: "active",
|
|
1110
1043
|
mode: accessMode,
|
|
1044
|
+
daemonSocketPath,
|
|
1111
1045
|
},
|
|
1112
1046
|
logger,
|
|
1113
1047
|
);
|
|
1114
1048
|
|
|
1049
|
+
// Query the daemon for discovered pages.
|
|
1050
|
+
const pages = await client.pages();
|
|
1051
|
+
|
|
1115
1052
|
logger.info("connect-success", { cdpUrl: endpoint, session, port });
|
|
1116
1053
|
console.log(`Connected to ${endpoint} (session: ${session})`);
|
|
1117
1054
|
console.log(` Pages found: ${pages.length}`);
|
|
1118
1055
|
if (pages.length > 0) {
|
|
1119
1056
|
for (const p of pages.slice(0, 5)) {
|
|
1120
|
-
console.log(` ${p.url
|
|
1057
|
+
console.log(` ${p.url}`);
|
|
1121
1058
|
}
|
|
1122
1059
|
if (pages.length > 5) {
|
|
1123
1060
|
console.log(` ... and ${pages.length - 5} more`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration types for the browser daemon process.
|
|
3
|
+
*
|
|
4
|
+
* Serialized as JSON in `process.argv[2]` when spawning the daemon.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Config for daemon-managed browser launch (`libretto open`).
|
|
9
|
+
* The daemon owns the browser lifecycle and will close it on shutdown.
|
|
10
|
+
*/
|
|
11
|
+
export type DaemonLaunchConfig = {
|
|
12
|
+
port: number;
|
|
13
|
+
url: string;
|
|
14
|
+
session: string;
|
|
15
|
+
headed: boolean;
|
|
16
|
+
viewport: { width: number; height: number };
|
|
17
|
+
storageStatePath?: string;
|
|
18
|
+
windowPosition?: { x: number; y: number };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Config for connecting to an externally managed browser (`libretto connect`).
|
|
23
|
+
* The daemon borrows the CDP connection and will disconnect (not close) on
|
|
24
|
+
* shutdown — the browser outlives the session.
|
|
25
|
+
*/
|
|
26
|
+
export type DaemonConnectConfig = {
|
|
27
|
+
mode: "connect";
|
|
28
|
+
session: string;
|
|
29
|
+
cdpEndpoint: string;
|
|
30
|
+
/** If set, the daemon navigates to this URL after connecting. */
|
|
31
|
+
url?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Discriminated union passed as JSON in `process.argv[2]`.
|
|
36
|
+
* Launch configs omit `mode` for backward compatibility with existing
|
|
37
|
+
* `runOpen()` callers — any config without `mode: "connect"` is treated
|
|
38
|
+
* as a launch config.
|
|
39
|
+
*/
|
|
40
|
+
export type DaemonConfig = DaemonLaunchConfig | DaemonConnectConfig;
|
|
41
|
+
|
|
42
|
+
export function isConnectConfig(
|
|
43
|
+
config: DaemonConfig,
|
|
44
|
+
): config is DaemonConnectConfig {
|
|
45
|
+
return "mode" in config && config.mode === "connect";
|
|
46
|
+
}
|