libretto 0.6.10 → 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.
Files changed (119) hide show
  1. package/README.md +4 -0
  2. package/README.template.md +4 -0
  3. package/dist/cli/cli.js +4 -3
  4. package/dist/cli/commands/ai.js +3 -2
  5. package/dist/cli/commands/browser.js +17 -17
  6. package/dist/cli/commands/execution.js +254 -234
  7. package/dist/cli/commands/experiments.js +100 -0
  8. package/dist/cli/commands/setup.js +20 -34
  9. package/dist/cli/commands/shared.js +10 -0
  10. package/dist/cli/commands/snapshot.js +81 -9
  11. package/dist/cli/commands/status.js +5 -4
  12. package/dist/cli/core/ai-model.js +6 -3
  13. package/dist/cli/core/browser.js +300 -121
  14. package/dist/cli/core/config.js +4 -2
  15. package/dist/cli/core/context.js +4 -0
  16. package/dist/cli/core/daemon/config.js +0 -6
  17. package/dist/cli/core/daemon/daemon.js +535 -89
  18. package/dist/cli/core/daemon/ipc.js +170 -129
  19. package/dist/cli/core/daemon/snapshot.js +72 -6
  20. package/dist/cli/core/experiments.js +66 -0
  21. package/dist/cli/core/session.js +5 -4
  22. package/dist/cli/core/skill-version.js +2 -1
  23. package/dist/cli/core/snapshot-analyzer.js +4 -3
  24. package/dist/cli/core/workflow-runner/runner.js +147 -0
  25. package/dist/cli/core/workflow-runtime.js +60 -0
  26. package/dist/cli/router.js +4 -1
  27. package/dist/shared/debug/pause-handler.d.ts +9 -0
  28. package/dist/shared/debug/pause-handler.js +15 -0
  29. package/dist/shared/debug/pause.d.ts +1 -2
  30. package/dist/shared/debug/pause.js +13 -36
  31. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  32. package/dist/shared/ipc/child-process-transport.js +60 -0
  33. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  34. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  35. package/dist/shared/ipc/ipc.d.ts +46 -0
  36. package/dist/shared/ipc/ipc.js +165 -0
  37. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  38. package/dist/shared/ipc/ipc.spec.js +114 -0
  39. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  40. package/dist/shared/ipc/socket-transport.js +143 -0
  41. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  42. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  43. package/dist/shared/package-manager.d.ts +7 -0
  44. package/dist/shared/package-manager.js +60 -0
  45. package/dist/shared/paths/paths.d.ts +1 -8
  46. package/dist/shared/paths/paths.js +1 -49
  47. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  48. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  49. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  50. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  51. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  52. package/dist/shared/snapshot/render-snapshot.js +651 -0
  53. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  54. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  55. package/dist/shared/snapshot/types.d.ts +40 -0
  56. package/dist/shared/snapshot/types.js +0 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  58. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  59. package/dist/shared/state/session-state.d.ts +1 -0
  60. package/dist/shared/state/session-state.js +1 -0
  61. package/docs/experiments.md +67 -0
  62. package/package.json +4 -2
  63. package/skills/libretto/SKILL.md +3 -1
  64. package/skills/libretto-readonly/SKILL.md +1 -1
  65. package/src/cli/AGENTS.md +7 -0
  66. package/src/cli/cli.ts +4 -3
  67. package/src/cli/commands/ai.ts +3 -2
  68. package/src/cli/commands/browser.ts +13 -11
  69. package/src/cli/commands/execution.ts +303 -271
  70. package/src/cli/commands/experiments.ts +120 -0
  71. package/src/cli/commands/setup.ts +18 -36
  72. package/src/cli/commands/shared.ts +20 -0
  73. package/src/cli/commands/snapshot.ts +99 -11
  74. package/src/cli/commands/status.ts +5 -4
  75. package/src/cli/core/ai-model.ts +6 -3
  76. package/src/cli/core/browser.ts +369 -147
  77. package/src/cli/core/config.ts +3 -1
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +686 -106
  81. package/src/cli/core/daemon/ipc.ts +330 -214
  82. package/src/cli/core/daemon/snapshot.ts +106 -8
  83. package/src/cli/core/experiments.ts +85 -0
  84. package/src/cli/core/session.ts +5 -4
  85. package/src/cli/core/skill-version.ts +2 -1
  86. package/src/cli/core/snapshot-analyzer.ts +4 -3
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +85 -0
  89. package/src/cli/router.ts +4 -1
  90. package/src/shared/debug/pause-handler.ts +20 -0
  91. package/src/shared/debug/pause.ts +14 -48
  92. package/src/shared/ipc/AGENTS.md +24 -0
  93. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  94. package/src/shared/ipc/child-process-transport.ts +96 -0
  95. package/src/shared/ipc/ipc.spec.ts +161 -0
  96. package/src/shared/ipc/ipc.ts +288 -0
  97. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  98. package/src/shared/ipc/socket-transport.ts +189 -0
  99. package/src/shared/package-manager.ts +76 -0
  100. package/src/shared/paths/paths.ts +0 -72
  101. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  102. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  103. package/src/shared/snapshot/render-snapshot.ts +962 -0
  104. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  105. package/src/shared/snapshot/types.ts +43 -0
  106. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  107. package/src/shared/state/session-state.ts +1 -0
  108. package/dist/cli/core/daemon/index.js +0 -16
  109. package/dist/cli/core/daemon/spawn.js +0 -90
  110. package/dist/cli/core/pause-signals.js +0 -29
  111. package/dist/cli/workers/run-integration-runtime.js +0 -235
  112. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  113. package/dist/cli/workers/run-integration-worker.js +0 -64
  114. package/src/cli/core/daemon/index.ts +0 -24
  115. package/src/cli/core/daemon/spawn.ts +0 -171
  116. package/src/cli/core/pause-signals.ts +0 -35
  117. package/src/cli/workers/run-integration-runtime.ts +0 -326
  118. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  119. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -25,30 +25,69 @@ import {
25
25
  type Page,
26
26
  } from "playwright";
27
27
  import { mkdir } from "node:fs/promises";
28
- import { appendFileSync } from "node:fs";
28
+ import { appendFileSync, existsSync, writeFileSync } from "node:fs";
29
29
  import { installSessionTelemetry } from "../session-telemetry.js";
30
+ import {
31
+ createIpcPeer,
32
+ type IpcPeer,
33
+ type IpcPeerHandlers,
34
+ } from "../../../shared/ipc/ipc.js";
35
+ import {
36
+ createIpcSocketServer,
37
+ listenOnIpcSocket,
38
+ } from "../../../shared/ipc/socket-transport.js";
30
39
  import {
31
40
  createLoggerForSession,
32
41
  getSessionDir,
33
42
  getSessionNetworkLogPath,
34
43
  getSessionActionsLogPath,
44
+ getSessionProviderClosePath,
45
+ getSessionStatePath,
35
46
  } from "../context.js";
36
47
  import type { LoggerApi } from "../../../shared/logger/index.js";
48
+ import type { ExportedLibrettoWorkflow } from "../../../shared/workflow/workflow.js";
37
49
  import {
38
- DaemonServer,
39
50
  getDaemonSocketPath,
40
- type DaemonRequest,
51
+ type CliToDaemonApi,
52
+ type DaemonCloseResult,
53
+ type DaemonExecOutput,
54
+ type DaemonExecResult,
55
+ type DaemonToCliApi,
41
56
  } from "./ipc.js";
42
57
  import { wrapPageForActionLogging } from "../telemetry.js";
58
+ import {
59
+ getProfilePath,
60
+ hasProfile,
61
+ normalizeDomain,
62
+ normalizeUrl,
63
+ } from "../browser.js";
43
64
  import { handlePages } from "./pages.js";
44
65
  import { handleExec, handleReadonlyExec } from "./exec.js";
45
- import { handleSnapshot } from "./snapshot.js";
66
+ import { handleCompactSnapshot, handleSnapshot } from "./snapshot.js";
67
+ import { librettoCommand } from "../../../shared/package-manager.js";
68
+ import type { Snapshot } from "../../../shared/snapshot/types.js";
69
+ import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
70
+ import { diffSnapshots } from "../../../shared/snapshot/diff-snapshots.js";
71
+ import {
72
+ installPageStabilityWaiter,
73
+ preparePageStabilityWait,
74
+ waitForPageStable,
75
+ } from "../../../shared/snapshot/wait-for-page-stable.js";
46
76
  import {
47
- isConnectConfig,
48
77
  type DaemonConfig,
49
- type DaemonLaunchConfig,
50
- type DaemonConnectConfig,
78
+ type DaemonBrowserLaunchConfig,
79
+ type DaemonBrowserConnectConfig,
80
+ type DaemonBrowserProviderConfig,
81
+ type DaemonWorkflowConfig,
51
82
  } from "./config.js";
83
+ import type { Experiments } from "../experiments.js";
84
+ import { getCloudProviderApi } from "../providers/index.js";
85
+ import type { ProviderApi } from "../providers/types.js";
86
+ import {
87
+ getAbsoluteIntegrationPath,
88
+ loadDefaultWorkflow,
89
+ } from "../workflow-runtime.js";
90
+ import { WorkflowController } from "../workflow-runner/runner.js";
52
91
 
53
92
  function isOperationalPage(page: Page): boolean {
54
93
  const url = page.url();
@@ -56,6 +95,63 @@ function isOperationalPage(page: Page): boolean {
56
95
  }
57
96
 
58
97
  type TelemetryEntry = Record<string, unknown>;
98
+ type ErrorWithOutput = Error & { output?: DaemonExecOutput };
99
+ type ShutdownOptions = { keepIpcClientsAlive?: boolean };
100
+ type ShutdownHandler = (options: ShutdownOptions) => Promise<void> | void;
101
+
102
+ async function waitForSessionState(session: string): Promise<void> {
103
+ const deadline = Date.now() + 2_000;
104
+ while (Date.now() < deadline) {
105
+ if (existsSync(getSessionStatePath(session))) return;
106
+ await new Promise((resolve) => setTimeout(resolve, 25));
107
+ }
108
+ throw new Error(
109
+ `Session state was not written before workflow start for "${session}".`,
110
+ );
111
+ }
112
+
113
+ class UserFacingStartupError extends Error {
114
+ constructor(message: string) {
115
+ super(message);
116
+ this.name = "UserFacingStartupError";
117
+ }
118
+ }
119
+
120
+ function getMissingLocalAuthProfileError(args: {
121
+ normalizedDomain: string;
122
+ profilePath: string;
123
+ session: string;
124
+ }): string {
125
+ return [
126
+ `Local auth profile not found for domain "${args.normalizedDomain}".`,
127
+ `Expected profile file: ${args.profilePath}`,
128
+ "To create it:",
129
+ ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
130
+ " 2. Log in manually in the browser window.",
131
+ ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`,
132
+ ].join("\n");
133
+ }
134
+
135
+ function resolveAuthProfileStorageStatePath(args: {
136
+ authProfileDomain?: string;
137
+ session: string;
138
+ }): string | undefined {
139
+ if (!args.authProfileDomain) return undefined;
140
+ const normalizedDomain = normalizeDomain(
141
+ normalizeUrl(args.authProfileDomain),
142
+ );
143
+ const profilePath = getProfilePath(normalizedDomain);
144
+ if (!hasProfile(normalizedDomain)) {
145
+ throw new UserFacingStartupError(
146
+ getMissingLocalAuthProfileError({
147
+ normalizedDomain,
148
+ profilePath,
149
+ session: args.session,
150
+ }),
151
+ );
152
+ }
153
+ return profilePath;
154
+ }
59
155
 
60
156
  // ── BrowserDaemon ──────────────────────────────────────────────────────
61
157
 
@@ -66,21 +162,29 @@ class BrowserDaemon {
66
162
  readonly logger: LoggerApi;
67
163
  private readonly execState: Record<string, unknown> = {};
68
164
  private readonly pageById = new Map<string, Page>();
165
+ private readonly shutdownHandlers: ShutdownHandler[] = [];
166
+ private readonly connectedClis = new Set<IpcPeer<DaemonToCliApi>>();
167
+ private workflowController: WorkflowController | undefined;
168
+ private shutdownPromise: Promise<DaemonCloseResult> | undefined;
169
+ private readonly latestCompactSnapshotByPage = new WeakMap<Page, Snapshot>();
69
170
 
70
171
  private constructor(
71
172
  private readonly session: string,
173
+ private readonly experiments: Experiments,
72
174
  private readonly externallyManaged: boolean,
73
175
  private readonly browser: Browser,
74
176
  private readonly context: BrowserContext,
75
177
  private readonly page: Page,
76
- private readonly ipcServer: DaemonServer,
77
178
  logger: LoggerApi,
179
+ private readonly providerSession?: {
180
+ provider: ProviderApi;
181
+ name: string;
182
+ sessionId: string;
183
+ },
78
184
  ) {
79
185
  this.logger = logger.withScope("child");
80
186
  }
81
187
 
82
- private shuttingDown = false;
83
-
84
188
  private trackPage(page: Page): string {
85
189
  const id = `page-${Math.random().toString(36).slice(2, 5)}`;
86
190
  this.pageById.set(id, page);
@@ -88,6 +192,16 @@ class BrowserDaemon {
88
192
  return id;
89
193
  }
90
194
 
195
+ private async installCompactSnapshotWaiter(page: Page): Promise<void> {
196
+ const result = await preparePageStabilityWait(page, { timeoutMs: 1_000 });
197
+ if (!result.ok) {
198
+ this.logger.warn("compact-snapshot-waiter-install-incomplete", {
199
+ session: this.session,
200
+ diagnostics: result.diagnostics,
201
+ });
202
+ }
203
+ }
204
+
91
205
  // ── Shared initialization ──────────────────────────────────────────
92
206
 
93
207
  /**
@@ -97,6 +211,7 @@ class BrowserDaemon {
97
211
  */
98
212
  private static async initialize(args: {
99
213
  session: string;
214
+ experiments: Experiments;
100
215
  externallyManaged: boolean;
101
216
  browser: Browser;
102
217
  context: BrowserContext;
@@ -104,15 +219,29 @@ class BrowserDaemon {
104
219
  initialPages: Page[];
105
220
  /** If set, navigate to this URL after telemetry but before starting IPC. */
106
221
  navigateUrl?: string;
222
+ readyProvider?: {
223
+ name: string;
224
+ sessionId: string;
225
+ cdpEndpoint: string;
226
+ liveViewUrl?: string;
227
+ };
228
+ providerSession?: {
229
+ provider: ProviderApi;
230
+ name: string;
231
+ sessionId: string;
232
+ };
107
233
  }): Promise<BrowserDaemon> {
108
234
  const {
109
235
  session,
236
+ experiments,
110
237
  externallyManaged,
111
238
  browser,
112
239
  context,
113
240
  page,
114
241
  initialPages,
115
242
  navigateUrl,
243
+ readyProvider,
244
+ providerSession,
116
245
  } = args;
117
246
 
118
247
  await mkdir(getSessionDir(session), { recursive: true });
@@ -142,21 +271,22 @@ class BrowserDaemon {
142
271
  });
143
272
  }
144
273
 
145
- // IPC server — handler is wired after construction to avoid a
146
- // circular type inference issue (daemon references itself).
274
+ if (experiments["compact-snapshot-format"]) {
275
+ await context.addInitScript(installPageStabilityWaiter);
276
+ }
277
+
278
+ // IPC server — typed handlers are attached per client connection so one
279
+ // daemon lifetime can serve multiple CLI invocations.
147
280
  const socketPath = getDaemonSocketPath(session);
148
- let handler: (request: DaemonRequest) => Promise<unknown>;
149
- const ipcServer = new DaemonServer(socketPath, (request) =>
150
- handler(request),
151
- );
152
281
  const daemon = new BrowserDaemon(
153
282
  session,
283
+ experiments,
154
284
  externallyManaged,
155
285
  browser,
156
286
  context,
157
287
  page,
158
- ipcServer,
159
288
  logger,
289
+ providerSession,
160
290
  );
161
291
 
162
292
  // Action logging and page tracking must be registered before optional
@@ -165,9 +295,19 @@ class BrowserDaemon {
165
295
  wrapPageForActionLogging(p, session);
166
296
  daemon.trackPage(p);
167
297
  }
298
+ if (experiments["compact-snapshot-format"]) {
299
+ await Promise.all(
300
+ initialPages.map((initialPage) =>
301
+ daemon.installCompactSnapshotWaiter(initialPage),
302
+ ),
303
+ );
304
+ }
168
305
  context.on("page", (newPage) => {
169
306
  wrapPageForActionLogging(newPage, session);
170
307
  daemon.trackPage(newPage);
308
+ if (experiments["compact-snapshot-format"]) {
309
+ void daemon.installCompactSnapshotWaiter(newPage);
310
+ }
171
311
  });
172
312
 
173
313
  // Navigate after telemetry is installed (so we capture the initial
@@ -177,8 +317,42 @@ class BrowserDaemon {
177
317
  await page.goto(navigateUrl);
178
318
  }
179
319
 
180
- handler = (request) => daemon.handleRequest(request);
181
- await ipcServer.listen();
320
+ const ipcServer = createIpcSocketServer((transport) => {
321
+ const cli = createIpcPeer<DaemonToCliApi, CliToDaemonApi>(
322
+ transport,
323
+ daemon.createIpcHandlers(),
324
+ );
325
+ const stopTracking = transport.onClose?.(() => {
326
+ daemon.connectedClis.delete(cli);
327
+ stopTracking?.();
328
+ });
329
+ daemon.connectedClis.add(cli);
330
+ });
331
+ daemon.registerShutdownHandler(async (options) => {
332
+ if (!options.keepIpcClientsAlive) {
333
+ for (const cli of daemon.connectedClis) {
334
+ cli.destroy();
335
+ }
336
+ daemon.connectedClis.clear();
337
+ }
338
+ if (options.keepIpcClientsAlive) {
339
+ ipcServer.close((error) => {
340
+ if (error) {
341
+ daemon.logger.warn("ipc-server-close-failed", {
342
+ session,
343
+ error,
344
+ });
345
+ }
346
+ });
347
+ return;
348
+ }
349
+ await new Promise<void>((resolve, reject) => {
350
+ ipcServer.close((error) => (error ? reject(error) : resolve()));
351
+ });
352
+ });
353
+
354
+ await listenOnIpcSocket(ipcServer, socketPath);
355
+ process.send?.({ type: "ready", socketPath, provider: readyProvider });
182
356
  daemon.logger.info("ipc-server-listening", { socketPath });
183
357
 
184
358
  browser.on("disconnected", () => {
@@ -190,7 +364,13 @@ class BrowserDaemon {
190
364
 
191
365
  // ── Launch mode ────────────────────────────────────────────────────
192
366
 
193
- static async launchBrowser(config: DaemonLaunchConfig): Promise<BrowserDaemon> {
367
+ static async launchBrowser(args: {
368
+ session: string;
369
+ experiments: Experiments;
370
+ browser: DaemonBrowserLaunchConfig;
371
+ workflow?: DaemonWorkflowConfig;
372
+ }): Promise<BrowserDaemon> {
373
+ const { session, browser: config } = args;
194
374
  const windowPositionArg = config.windowPosition
195
375
  ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
196
376
  : undefined;
@@ -199,17 +379,24 @@ class BrowserDaemon {
199
379
  headless: !config.headed,
200
380
  args: [
201
381
  "--disable-blink-features=AutomationControlled",
202
- `--remote-debugging-port=${config.port}`,
382
+ ...(config.remoteDebuggingPort
383
+ ? [`--remote-debugging-port=${config.remoteDebuggingPort}`]
384
+ : []),
203
385
  "--remote-debugging-address=127.0.0.1",
204
386
  "--no-focus-on-check",
205
387
  ...(windowPositionArg ? [windowPositionArg] : []),
206
388
  ],
207
389
  });
208
390
 
391
+ const storageStatePath =
392
+ config.storageStatePath ??
393
+ resolveAuthProfileStorageStatePath({
394
+ authProfileDomain: args.workflow?.authProfileDomain,
395
+ session,
396
+ });
397
+
209
398
  const context = await browser.newContext({
210
- ...(config.storageStatePath
211
- ? { storageState: config.storageStatePath }
212
- : {}),
399
+ ...(storageStatePath ? { storageState: storageStatePath } : {}),
213
400
  viewport: {
214
401
  width: config.viewport.width,
215
402
  height: config.viewport.height,
@@ -223,19 +410,20 @@ class BrowserDaemon {
223
410
  page.setDefaultNavigationTimeout(45000);
224
411
 
225
412
  const daemon = await BrowserDaemon.initialize({
226
- session: config.session,
413
+ session,
414
+ experiments: args.experiments,
227
415
  externallyManaged: false,
228
416
  browser,
229
417
  context,
230
418
  page,
231
419
  initialPages: [page],
232
- navigateUrl: config.url,
420
+ navigateUrl: config.initialUrl,
233
421
  });
234
422
 
235
423
  daemon.logger.info("child-launched", {
236
- port: config.port,
424
+ port: config.remoteDebuggingPort,
237
425
  pid: process.pid,
238
- session: config.session,
426
+ session,
239
427
  });
240
428
 
241
429
  return daemon;
@@ -243,9 +431,12 @@ class BrowserDaemon {
243
431
 
244
432
  // ── Connect mode ───────────────────────────────────────────────────
245
433
 
246
- static async connectToEndpoint(
247
- config: DaemonConnectConfig,
248
- ): Promise<BrowserDaemon> {
434
+ static async connectToEndpoint(args: {
435
+ session: string;
436
+ experiments: Experiments;
437
+ browser: DaemonBrowserConnectConfig;
438
+ }): Promise<BrowserDaemon> {
439
+ const { session, browser: config } = args;
249
440
  const browser = await chromium.connectOverCDP(config.cdpEndpoint);
250
441
 
251
442
  // Discover existing contexts and pages.
@@ -259,47 +450,168 @@ class BrowserDaemon {
259
450
  : await context.newPage();
260
451
 
261
452
  const daemon = await BrowserDaemon.initialize({
262
- session: config.session,
453
+ session,
454
+ experiments: args.experiments,
263
455
  externallyManaged: true,
264
456
  browser,
265
457
  context,
266
458
  page,
267
- initialPages:
268
- operationalPages.length > 0 ? operationalPages : [page],
269
- navigateUrl: config.url,
459
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
460
+ navigateUrl: config.initialUrl,
270
461
  });
271
462
 
272
463
  daemon.logger.info("child-connected", {
273
464
  cdpEndpoint: config.cdpEndpoint,
274
- url: config.url,
465
+ url: config.initialUrl,
275
466
  pid: process.pid,
276
- session: config.session,
467
+ session,
277
468
  });
278
469
 
279
470
  return daemon;
280
471
  }
281
472
 
473
+ static async connectToProvider(args: {
474
+ session: string;
475
+ experiments: Experiments;
476
+ browser: DaemonBrowserProviderConfig;
477
+ }): Promise<BrowserDaemon> {
478
+ const { session, browser: config } = args;
479
+ const provider = getCloudProviderApi(config.providerName);
480
+ const providerSession = await provider.createSession();
481
+ try {
482
+ const browser = await chromium.connectOverCDP(
483
+ providerSession.cdpEndpoint,
484
+ );
485
+
486
+ const contexts = browser.contexts();
487
+ const context =
488
+ contexts.length > 0 ? contexts[0] : await browser.newContext();
489
+ const operationalPages = context.pages().filter(isOperationalPage);
490
+ const page =
491
+ operationalPages.length > 0
492
+ ? operationalPages[operationalPages.length - 1]
493
+ : await context.newPage();
494
+
495
+ const daemon = await BrowserDaemon.initialize({
496
+ session,
497
+ experiments: args.experiments,
498
+ externallyManaged: true,
499
+ browser,
500
+ context,
501
+ page,
502
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
503
+ navigateUrl: config.initialUrl,
504
+ readyProvider: {
505
+ name: config.providerName,
506
+ sessionId: providerSession.sessionId,
507
+ cdpEndpoint: providerSession.cdpEndpoint,
508
+ liveViewUrl: providerSession.liveViewUrl,
509
+ },
510
+ providerSession: {
511
+ provider,
512
+ name: config.providerName,
513
+ sessionId: providerSession.sessionId,
514
+ },
515
+ });
516
+
517
+ daemon.logger.info("child-provider-connected", {
518
+ provider: config.providerName,
519
+ sessionId: providerSession.sessionId,
520
+ url: config.initialUrl,
521
+ pid: process.pid,
522
+ session,
523
+ });
524
+
525
+ return daemon;
526
+ } catch (error) {
527
+ await provider.closeSession(providerSession.sessionId);
528
+ throw error;
529
+ }
530
+ }
531
+
282
532
  // ── Lifecycle ──────────────────────────────────────────────────────
283
533
 
284
- async shutdown(reason: string, closeBrowser: boolean): Promise<void> {
285
- if (this.shuttingDown) return;
286
- this.shuttingDown = true;
287
- this.logger.info(reason, { session: this.session });
288
- await this.ipcServer.close();
289
- if (!closeBrowser) return;
290
- if (this.externallyManaged) {
291
- // Drop the CDP pipe without killing the external browser.
292
- try {
293
- (
294
- this.browser as unknown as {
295
- _connection?: { close(): void };
534
+ registerShutdownHandler(handler: ShutdownHandler): void {
535
+ this.shutdownHandlers.push(handler);
536
+ }
537
+
538
+ shutdown(
539
+ reason: string,
540
+ closeBrowser: boolean,
541
+ options: ShutdownOptions = {},
542
+ ): Promise<DaemonCloseResult> {
543
+ if (this.shutdownPromise) return this.shutdownPromise;
544
+ this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
545
+ return this.shutdownPromise;
546
+ }
547
+
548
+ private async performShutdown(
549
+ reason: string,
550
+ closeBrowser: boolean,
551
+ options: ShutdownOptions,
552
+ ): Promise<DaemonCloseResult> {
553
+ let replayUrl: string | undefined;
554
+ try {
555
+ this.logger.info(reason, { session: this.session });
556
+ for (const handler of this.shutdownHandlers) {
557
+ await handler(options);
558
+ }
559
+ if (closeBrowser) {
560
+ if (this.externallyManaged) {
561
+ // Playwright does not expose a public "detach from this CDP browser"
562
+ // API. Closing the private connection lets the daemon's event loop
563
+ // drain without asking Playwright to close the externally managed
564
+ // browser/provider session itself.
565
+ try {
566
+ (
567
+ this.browser as unknown as {
568
+ _connection?: { close(): void };
569
+ }
570
+ )._connection?.close();
571
+ } catch {
572
+ // Connection may already be closed.
573
+ }
574
+ } else {
575
+ await this.browser.close();
576
+ }
577
+ }
578
+ if (this.providerSession) {
579
+ const result = await this.providerSession.provider.closeSession(
580
+ this.providerSession.sessionId,
581
+ );
582
+ replayUrl = result.replayUrl;
583
+ if (result.replayUrl) {
584
+ this.logger.info("provider-recording", {
585
+ session: this.session,
586
+ provider: this.providerSession.name,
587
+ sessionId: this.providerSession.sessionId,
588
+ replayUrl: result.replayUrl,
589
+ });
590
+ }
591
+ writeFileSync(
592
+ getSessionProviderClosePath(this.session),
593
+ JSON.stringify(
594
+ {
595
+ provider: this.providerSession.name,
596
+ sessionId: this.providerSession.sessionId,
597
+ replayUrl: result.replayUrl,
598
+ },
599
+ null,
600
+ 2,
601
+ ),
602
+ "utf8",
603
+ );
604
+ }
605
+ return replayUrl ? { replayUrl } : {};
606
+ } finally {
607
+ if (options.keepIpcClientsAlive) {
608
+ setImmediate(() => {
609
+ for (const cli of this.connectedClis) {
610
+ cli.destroy();
296
611
  }
297
- )._connection?.close();
298
- } catch {
299
- // Connection may already be closed.
612
+ this.connectedClis.clear();
613
+ });
300
614
  }
301
- } else {
302
- await this.browser.close();
303
615
  }
304
616
  }
305
617
 
@@ -307,82 +619,281 @@ class BrowserDaemon {
307
619
 
308
620
  private resolveTargetPage(pageId?: string): Page {
309
621
  if (!pageId) {
310
- if (this.pageById.size > 1) {
622
+ if (this.page.isClosed()) {
623
+ const openPages = Array.from(this.pageById.values());
624
+ if (openPages.length === 1) return openPages[0];
311
625
  throw new Error(
312
- `Multiple pages are open in session "${this.session}". Pass --page <id> to target a page (run "libretto pages --session ${this.session}" to list ids).`,
626
+ `The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`,
313
627
  );
314
628
  }
315
- // Return the single tracked page rather than `this.page` — the
316
- // initial page may have been closed and replaced by a new one.
317
- if (this.pageById.size === 1) {
318
- return this.pageById.values().next().value!;
319
- }
320
629
  return this.page;
321
630
  }
322
631
  const page = this.pageById.get(pageId);
323
632
  if (!page) {
324
633
  throw new Error(
325
- `Page "${pageId}" was not found in session "${this.session}". Run "libretto pages --session ${this.session}" to list ids.`,
634
+ `Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`,
326
635
  );
327
636
  }
328
637
  return page;
329
638
  }
330
639
 
331
- // ── IPC handler ────────────────────────────────────────────────────
640
+ // ── IPC handlers ───────────────────────────────────────────────────
641
+
642
+ private createIpcHandlers(): IpcPeerHandlers<CliToDaemonApi> {
643
+ return {
644
+ ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
645
+ pages: () =>
646
+ this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
647
+ exec: (args) => this.runExec(args),
648
+ readonlyExec: (args) => this.runReadonlyExec(args),
649
+ snapshot: (args) => this.runSnapshot(args),
650
+ getWorkflowStatus: () => this.getWorkflowStatus(),
651
+ resumeWorkflow: () => this.resumeWorkflow(),
652
+ close: () =>
653
+ this.shutdown("ipc-close", true, {
654
+ keepIpcClientsAlive: true,
655
+ }),
656
+ };
657
+ }
332
658
 
333
- private async handleRequest(request: DaemonRequest): Promise<unknown> {
334
- if (request.command === "ping") {
335
- return { protocolVersion: PROTOCOL_VERSION };
659
+ private async runSnapshot(
660
+ args: Parameters<CliToDaemonApi["snapshot"]>[0],
661
+ ): Promise<ReturnType<CliToDaemonApi["snapshot"]>> {
662
+ if (args.mode === "compact") {
663
+ if (!this.experiments["compact-snapshot-format"]) {
664
+ throw new Error(
665
+ `The compact-snapshot-format experiment is not enabled for session "${this.session}". ` +
666
+ `Close and reopen the session after running ${librettoCommand("experiments enable compact-snapshot-format")}.`,
667
+ );
668
+ }
669
+ const targetPage = this.resolveTargetPage(args.pageId);
670
+ const result = await this.withRequestTimeout(() =>
671
+ handleCompactSnapshot(
672
+ targetPage,
673
+ this.session,
674
+ this.logger,
675
+ {
676
+ pageId: args.pageId,
677
+ cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
678
+ useCachedSnapshot: args.useCachedSnapshot,
679
+ },
680
+ ),
681
+ );
682
+ if (!args.useCachedSnapshot) {
683
+ this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
684
+ }
685
+ return result;
336
686
  }
337
687
 
688
+ return this.withRequestTimeout(() =>
689
+ handleSnapshot(
690
+ this.resolveTargetPage(args.pageId),
691
+ this.session,
692
+ this.logger,
693
+ args.pageId,
694
+ ),
695
+ );
696
+ }
697
+
698
+ private async withRequestTimeout<T>(
699
+ operation: () => Promise<T> | T,
700
+ ): Promise<T> {
338
701
  // All non-ping commands get a timeout guard. The timer is cleared
339
702
  // when the command settles to avoid orphaned timers that would
340
703
  // keep the event loop alive after shutdown.
341
- let timerId: ReturnType<typeof setTimeout>;
342
- return Promise.race([
343
- this.dispatchCommand(request).finally(() => clearTimeout(timerId)),
344
- new Promise<never>((_resolve, reject) => {
345
- timerId = setTimeout(
346
- () =>
347
- reject(
348
- new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`),
349
- ),
350
- REQUEST_TIMEOUT_MS,
351
- );
352
- }),
353
- ]);
704
+ let timerId: ReturnType<typeof setTimeout> | undefined;
705
+ const timeout = new Promise<never>((_resolve, reject) => {
706
+ timerId = setTimeout(
707
+ () =>
708
+ reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
709
+ REQUEST_TIMEOUT_MS,
710
+ );
711
+ });
712
+
713
+ try {
714
+ return await Promise.race([operation(), timeout]);
715
+ } finally {
716
+ if (timerId) clearTimeout(timerId);
717
+ }
354
718
  }
355
719
 
356
- private async dispatchCommand(request: DaemonRequest): Promise<unknown> {
357
- switch (request.command) {
358
- case "pages":
359
- return handlePages(this.pageById, this.page);
360
- case "exec":
361
- return handleExec(
362
- this.resolveTargetPage(request.pageId),
363
- request.code,
720
+ private async runExec(
721
+ args: Parameters<CliToDaemonApi["exec"]>[0],
722
+ ): Promise<DaemonExecResult> {
723
+ if (this.experiments["compact-snapshot-format"]) {
724
+ return this.runCompactExec(args);
725
+ }
726
+
727
+ try {
728
+ const data = await this.withRequestTimeout(() =>
729
+ handleExec(
730
+ this.resolveTargetPage(args.pageId),
731
+ args.code,
364
732
  this.context,
365
733
  this.browser,
366
734
  this.execState,
367
735
  this.session,
368
- request.visualize,
369
- );
370
- case "readonly-exec":
371
- return handleReadonlyExec(
372
- this.resolveTargetPage(request.pageId),
373
- request.code,
374
- );
375
- case "snapshot":
376
- return handleSnapshot(
377
- this.resolveTargetPage(request.pageId),
736
+ args.visualize,
737
+ ),
738
+ );
739
+ return { ok: true, data };
740
+ } catch (error) {
741
+ return this.createExecErrorResult(error);
742
+ }
743
+ }
744
+
745
+ private async runCompactExec(
746
+ args: Parameters<CliToDaemonApi["exec"]>[0],
747
+ ): Promise<DaemonExecResult> {
748
+ let targetPage: Page | undefined;
749
+ try {
750
+ targetPage = this.resolveTargetPage(args.pageId);
751
+ const page = targetPage;
752
+ const data = await this.withRequestTimeout(async () => {
753
+ const before =
754
+ this.latestCompactSnapshotByPage.get(page) ?? (await snapshot(page));
755
+ const result = await handleExec(
756
+ page,
757
+ args.code,
758
+ this.context,
759
+ this.browser,
760
+ this.execState,
378
761
  this.session,
379
- this.logger,
380
- request.pageId,
762
+ args.visualize,
381
763
  );
382
- default:
383
- throw new Error(
384
- `Unknown command: ${(request as { command: string }).command}`,
764
+
765
+ try {
766
+ const waitResult = await waitForPageStable(page);
767
+ if (!waitResult.ok) {
768
+ this.logger.warn("compact-exec-stability-wait-incomplete", {
769
+ session: this.session,
770
+ pageId: args.pageId,
771
+ diagnostics: waitResult.diagnostics,
772
+ });
773
+ }
774
+
775
+ const after = await snapshot(page);
776
+ const snapshotDiff = diffSnapshots(before, after);
777
+ this.latestCompactSnapshotByPage.set(page, after);
778
+ return { ...result, snapshotDiff };
779
+ } catch (error) {
780
+ this.latestCompactSnapshotByPage.delete(page);
781
+ this.logger.warn("compact-exec-diff-failed", {
782
+ session: this.session,
783
+ pageId: args.pageId,
784
+ error: error instanceof Error ? error.message : String(error),
785
+ });
786
+ return result;
787
+ }
788
+ });
789
+ return { ok: true, data };
790
+ } catch (error) {
791
+ if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
792
+ return this.createExecErrorResult(error);
793
+ }
794
+ }
795
+
796
+ private async runReadonlyExec(
797
+ args: Parameters<CliToDaemonApi["readonlyExec"]>[0],
798
+ ): Promise<DaemonExecResult> {
799
+ try {
800
+ const data = await this.withRequestTimeout(() =>
801
+ handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code),
802
+ );
803
+ return { ok: true, data };
804
+ } catch (error) {
805
+ return this.createExecErrorResult(error);
806
+ }
807
+ }
808
+
809
+ private createExecErrorResult(error: unknown): DaemonExecResult {
810
+ return {
811
+ ok: false,
812
+ message: error instanceof Error ? error.message : String(error),
813
+ output:
814
+ error instanceof Error ? (error as ErrorWithOutput).output : undefined,
815
+ };
816
+ }
817
+
818
+ startWorkflow(args: {
819
+ workflow: DaemonWorkflowConfig;
820
+ headed: boolean;
821
+ loadedWorkflow?: ExportedLibrettoWorkflow;
822
+ }): void {
823
+ if (this.workflowController) {
824
+ throw new Error("Workflow controller has already started.");
825
+ }
826
+
827
+ this.workflowController = new WorkflowController({
828
+ session: this.session,
829
+ headed: args.headed,
830
+ page: this.page,
831
+ context: this.context,
832
+ logger: this.logger,
833
+ onLog: (event) => {
834
+ this.broadcast("workflowOutput", event);
835
+ },
836
+ onOutcome: (outcome) => {
837
+ if (outcome.state === "paused") {
838
+ this.broadcast("workflowPaused", {
839
+ pausedAt: outcome.pausedAt,
840
+ url: outcome.url,
841
+ });
842
+ return;
843
+ }
844
+ this.broadcast(
845
+ "workflowFinished",
846
+ outcome.result === "completed"
847
+ ? { result: "completed", completedAt: outcome.completedAt }
848
+ : {
849
+ result: "failed",
850
+ message: outcome.message,
851
+ phase: outcome.phase,
852
+ },
385
853
  );
854
+ },
855
+ });
856
+ this.workflowController.start({
857
+ integrationPath: args.workflow.integrationPath,
858
+ params: args.workflow.params,
859
+ visualize: args.workflow.visualize,
860
+ loadedWorkflow: args.loadedWorkflow,
861
+ });
862
+ }
863
+
864
+ getWorkflowStatus(): ReturnType<WorkflowController["getStatus"]> {
865
+ return this.workflowController?.getStatus() ?? { state: "idle" };
866
+ }
867
+
868
+ resumeWorkflow(): void {
869
+ if (!this.workflowController) {
870
+ throw new Error("Workflow is not paused.");
871
+ }
872
+ this.workflowController.resume();
873
+ }
874
+
875
+ async broadcast<Name extends keyof DaemonToCliApi>(
876
+ name: Name,
877
+ message: Parameters<DaemonToCliApi[Name]>[0],
878
+ ): Promise<void> {
879
+ const results = await Promise.allSettled(
880
+ Array.from(this.connectedClis, (cli) => {
881
+ const call = cli.call[name] as (
882
+ message: Parameters<DaemonToCliApi[Name]>[0],
883
+ ) => Promise<void>;
884
+ return call(message);
885
+ }),
886
+ );
887
+ for (const result of results) {
888
+ if (result.status === "rejected") {
889
+ this.logger.warn("workflow-event-failed", {
890
+ event: name,
891
+ error:
892
+ result.reason instanceof Error
893
+ ? result.reason.message
894
+ : String(result.reason),
895
+ });
896
+ }
386
897
  }
387
898
  }
388
899
  }
@@ -391,10 +902,65 @@ class BrowserDaemon {
391
902
 
392
903
  async function main(): Promise<void> {
393
904
  const config = JSON.parse(process.argv[2]) as DaemonConfig;
905
+ const headed =
906
+ config.browser.kind === "launch" ? config.browser.headed : false;
394
907
 
395
- const daemon = isConnectConfig(config)
396
- ? await BrowserDaemon.connectToEndpoint(config)
397
- : await BrowserDaemon.launchBrowser(config);
908
+ let loadedWorkflow: ExportedLibrettoWorkflow | undefined;
909
+ if (config.workflow) {
910
+ try {
911
+ loadedWorkflow = await loadDefaultWorkflow(
912
+ getAbsoluteIntegrationPath(config.workflow.integrationPath),
913
+ );
914
+ } catch (error) {
915
+ throw new UserFacingStartupError(
916
+ error instanceof Error ? error.message : String(error),
917
+ );
918
+ }
919
+ }
920
+
921
+ const daemon =
922
+ config.browser.kind === "provider"
923
+ ? await BrowserDaemon.connectToProvider({
924
+ session: config.session,
925
+ experiments: config.experiments,
926
+ browser: config.browser,
927
+ })
928
+ : config.browser.kind === "connect"
929
+ ? await BrowserDaemon.connectToEndpoint({
930
+ session: config.session,
931
+ experiments: config.experiments,
932
+ browser: config.browser,
933
+ })
934
+ : await BrowserDaemon.launchBrowser({
935
+ session: config.session,
936
+ experiments: config.experiments,
937
+ browser: config.browser,
938
+ workflow: config.workflow,
939
+ });
940
+
941
+ if (config.workflow) {
942
+ void waitForSessionState(config.session)
943
+ .then(() =>
944
+ daemon.startWorkflow({
945
+ workflow: config.workflow!,
946
+ headed,
947
+ loadedWorkflow,
948
+ }),
949
+ )
950
+ .catch((error) => {
951
+ const message = error instanceof Error ? error.message : String(error);
952
+ daemon.logger.error("workflow-failed", {
953
+ error: message,
954
+ });
955
+ return daemon
956
+ .broadcast("workflowFinished", {
957
+ result: "failed" as const,
958
+ message,
959
+ phase: "setup" as const,
960
+ })
961
+ .finally(() => daemon.shutdown("workflow-start-failed", true));
962
+ });
963
+ }
398
964
 
399
965
  process.on("SIGTERM", () => {
400
966
  void daemon.shutdown("child-sigterm", true);
@@ -426,4 +992,18 @@ async function main(): Promise<void> {
426
992
  // letting the process exit naturally.
427
993
  }
428
994
 
429
- await main();
995
+ function reportStartupError(error: unknown): never {
996
+ if (error instanceof UserFacingStartupError) {
997
+ process.send?.({
998
+ type: "startup-error",
999
+ message: error.message,
1000
+ });
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+
1005
+ try {
1006
+ await main();
1007
+ } catch (error) {
1008
+ reportStartupError(error);
1009
+ }