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
@@ -2,51 +2,133 @@ import {
2
2
  chromium
3
3
  } from "playwright";
4
4
  import { mkdir } from "node:fs/promises";
5
- import { appendFileSync } from "node:fs";
5
+ import { appendFileSync, existsSync, writeFileSync } from "node:fs";
6
6
  import { installSessionTelemetry } from "../session-telemetry.js";
7
+ import {
8
+ createIpcPeer
9
+ } from "../../../shared/ipc/ipc.js";
10
+ import {
11
+ createIpcSocketServer,
12
+ listenOnIpcSocket
13
+ } from "../../../shared/ipc/socket-transport.js";
7
14
  import {
8
15
  createLoggerForSession,
9
16
  getSessionDir,
10
17
  getSessionNetworkLogPath,
11
- getSessionActionsLogPath
18
+ getSessionActionsLogPath,
19
+ getSessionProviderClosePath,
20
+ getSessionStatePath
12
21
  } from "../context.js";
13
22
  import {
14
- DaemonServer,
15
23
  getDaemonSocketPath
16
24
  } from "./ipc.js";
17
25
  import { wrapPageForActionLogging } from "../telemetry.js";
26
+ import {
27
+ getProfilePath,
28
+ hasProfile,
29
+ normalizeDomain,
30
+ normalizeUrl
31
+ } from "../browser.js";
18
32
  import { handlePages } from "./pages.js";
19
33
  import { handleExec, handleReadonlyExec } from "./exec.js";
20
- import { handleSnapshot } from "./snapshot.js";
34
+ import { handleCompactSnapshot, handleSnapshot } from "./snapshot.js";
35
+ import { librettoCommand } from "../../../shared/package-manager.js";
36
+ import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
37
+ import { diffSnapshots } from "../../../shared/snapshot/diff-snapshots.js";
38
+ import {
39
+ installPageStabilityWaiter,
40
+ preparePageStabilityWait,
41
+ waitForPageStable
42
+ } from "../../../shared/snapshot/wait-for-page-stable.js";
43
+ import { getCloudProviderApi } from "../providers/index.js";
21
44
  import {
22
- isConnectConfig
23
- } from "./config.js";
45
+ getAbsoluteIntegrationPath,
46
+ loadDefaultWorkflow
47
+ } from "../workflow-runtime.js";
48
+ import { WorkflowController } from "../workflow-runner/runner.js";
24
49
  function isOperationalPage(page) {
25
50
  const url = page.url();
26
51
  return !url.startsWith("devtools://") && !url.startsWith("chrome-error://");
27
52
  }
53
+ async function waitForSessionState(session) {
54
+ const deadline = Date.now() + 2e3;
55
+ while (Date.now() < deadline) {
56
+ if (existsSync(getSessionStatePath(session))) return;
57
+ await new Promise((resolve) => setTimeout(resolve, 25));
58
+ }
59
+ throw new Error(
60
+ `Session state was not written before workflow start for "${session}".`
61
+ );
62
+ }
63
+ class UserFacingStartupError extends Error {
64
+ constructor(message) {
65
+ super(message);
66
+ this.name = "UserFacingStartupError";
67
+ }
68
+ }
69
+ function getMissingLocalAuthProfileError(args) {
70
+ return [
71
+ `Local auth profile not found for domain "${args.normalizedDomain}".`,
72
+ `Expected profile file: ${args.profilePath}`,
73
+ "To create it:",
74
+ ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
75
+ " 2. Log in manually in the browser window.",
76
+ ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`
77
+ ].join("\n");
78
+ }
79
+ function resolveAuthProfileStorageStatePath(args) {
80
+ if (!args.authProfileDomain) return void 0;
81
+ const normalizedDomain = normalizeDomain(
82
+ normalizeUrl(args.authProfileDomain)
83
+ );
84
+ const profilePath = getProfilePath(normalizedDomain);
85
+ if (!hasProfile(normalizedDomain)) {
86
+ throw new UserFacingStartupError(
87
+ getMissingLocalAuthProfileError({
88
+ normalizedDomain,
89
+ profilePath,
90
+ session: args.session
91
+ })
92
+ );
93
+ }
94
+ return profilePath;
95
+ }
28
96
  const PROTOCOL_VERSION = 1;
29
97
  const REQUEST_TIMEOUT_MS = 6e4;
30
98
  class BrowserDaemon {
31
- constructor(session, externallyManaged, browser, context, page, ipcServer, logger) {
99
+ constructor(session, experiments, externallyManaged, browser, context, page, logger, providerSession) {
32
100
  this.session = session;
101
+ this.experiments = experiments;
33
102
  this.externallyManaged = externallyManaged;
34
103
  this.browser = browser;
35
104
  this.context = context;
36
105
  this.page = page;
37
- this.ipcServer = ipcServer;
106
+ this.providerSession = providerSession;
38
107
  this.logger = logger.withScope("child");
39
108
  }
40
109
  logger;
41
110
  execState = {};
42
111
  pageById = /* @__PURE__ */ new Map();
43
- shuttingDown = false;
112
+ shutdownHandlers = [];
113
+ connectedClis = /* @__PURE__ */ new Set();
114
+ workflowController;
115
+ shutdownPromise;
116
+ latestCompactSnapshotByPage = /* @__PURE__ */ new WeakMap();
44
117
  trackPage(page) {
45
118
  const id = `page-${Math.random().toString(36).slice(2, 5)}`;
46
119
  this.pageById.set(id, page);
47
120
  page.on("close", () => this.pageById.delete(id));
48
121
  return id;
49
122
  }
123
+ async installCompactSnapshotWaiter(page) {
124
+ const result = await preparePageStabilityWait(page, { timeoutMs: 1e3 });
125
+ if (!result.ok) {
126
+ this.logger.warn("compact-snapshot-waiter-install-incomplete", {
127
+ session: this.session,
128
+ diagnostics: result.diagnostics
129
+ });
130
+ }
131
+ }
50
132
  // ── Shared initialization ──────────────────────────────────────────
51
133
  /**
52
134
  * Common setup after the mode-specific code has obtained a browser,
@@ -56,12 +138,15 @@ class BrowserDaemon {
56
138
  static async initialize(args) {
57
139
  const {
58
140
  session,
141
+ experiments,
59
142
  externallyManaged,
60
143
  browser,
61
144
  context,
62
145
  page,
63
146
  initialPages,
64
- navigateUrl
147
+ navigateUrl,
148
+ readyProvider,
149
+ providerSession
65
150
  } = args;
66
151
  await mkdir(getSessionDir(session), { recursive: true });
67
152
  const networkLogFile = getSessionNetworkLogPath(session);
@@ -85,34 +170,76 @@ class BrowserDaemon {
85
170
  error: err instanceof Error ? err.message : String(err)
86
171
  });
87
172
  }
173
+ if (experiments["compact-snapshot-format"]) {
174
+ await context.addInitScript(installPageStabilityWaiter);
175
+ }
88
176
  const socketPath = getDaemonSocketPath(session);
89
- let handler;
90
- const ipcServer = new DaemonServer(
91
- socketPath,
92
- (request) => handler(request)
93
- );
94
177
  const daemon = new BrowserDaemon(
95
178
  session,
179
+ experiments,
96
180
  externallyManaged,
97
181
  browser,
98
182
  context,
99
183
  page,
100
- ipcServer,
101
- logger
184
+ logger,
185
+ providerSession
102
186
  );
103
187
  for (const p of initialPages) {
104
188
  wrapPageForActionLogging(p, session);
105
189
  daemon.trackPage(p);
106
190
  }
191
+ if (experiments["compact-snapshot-format"]) {
192
+ await Promise.all(
193
+ initialPages.map(
194
+ (initialPage) => daemon.installCompactSnapshotWaiter(initialPage)
195
+ )
196
+ );
197
+ }
107
198
  context.on("page", (newPage) => {
108
199
  wrapPageForActionLogging(newPage, session);
109
200
  daemon.trackPage(newPage);
201
+ if (experiments["compact-snapshot-format"]) {
202
+ void daemon.installCompactSnapshotWaiter(newPage);
203
+ }
110
204
  });
111
205
  if (navigateUrl) {
112
206
  await page.goto(navigateUrl);
113
207
  }
114
- handler = (request) => daemon.handleRequest(request);
115
- await ipcServer.listen();
208
+ const ipcServer = createIpcSocketServer((transport) => {
209
+ const cli = createIpcPeer(
210
+ transport,
211
+ daemon.createIpcHandlers()
212
+ );
213
+ const stopTracking = transport.onClose?.(() => {
214
+ daemon.connectedClis.delete(cli);
215
+ stopTracking?.();
216
+ });
217
+ daemon.connectedClis.add(cli);
218
+ });
219
+ daemon.registerShutdownHandler(async (options) => {
220
+ if (!options.keepIpcClientsAlive) {
221
+ for (const cli of daemon.connectedClis) {
222
+ cli.destroy();
223
+ }
224
+ daemon.connectedClis.clear();
225
+ }
226
+ if (options.keepIpcClientsAlive) {
227
+ ipcServer.close((error) => {
228
+ if (error) {
229
+ daemon.logger.warn("ipc-server-close-failed", {
230
+ session,
231
+ error
232
+ });
233
+ }
234
+ });
235
+ return;
236
+ }
237
+ await new Promise((resolve, reject) => {
238
+ ipcServer.close((error) => error ? reject(error) : resolve());
239
+ });
240
+ });
241
+ await listenOnIpcSocket(ipcServer, socketPath);
242
+ process.send?.({ type: "ready", socketPath, provider: readyProvider });
116
243
  daemon.logger.info("ipc-server-listening", { socketPath });
117
244
  browser.on("disconnected", () => {
118
245
  void daemon.shutdown("browser-disconnected-exiting", false);
@@ -120,20 +247,25 @@ class BrowserDaemon {
120
247
  return daemon;
121
248
  }
122
249
  // ── Launch mode ────────────────────────────────────────────────────
123
- static async launchBrowser(config) {
250
+ static async launchBrowser(args) {
251
+ const { session, browser: config } = args;
124
252
  const windowPositionArg = config.windowPosition ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}` : void 0;
125
253
  const browser = await chromium.launch({
126
254
  headless: !config.headed,
127
255
  args: [
128
256
  "--disable-blink-features=AutomationControlled",
129
- `--remote-debugging-port=${config.port}`,
257
+ ...config.remoteDebuggingPort ? [`--remote-debugging-port=${config.remoteDebuggingPort}`] : [],
130
258
  "--remote-debugging-address=127.0.0.1",
131
259
  "--no-focus-on-check",
132
260
  ...windowPositionArg ? [windowPositionArg] : []
133
261
  ]
134
262
  });
263
+ const storageStatePath = config.storageStatePath ?? resolveAuthProfileStorageStatePath({
264
+ authProfileDomain: args.workflow?.authProfileDomain,
265
+ session
266
+ });
135
267
  const context = await browser.newContext({
136
- ...config.storageStatePath ? { storageState: config.storageStatePath } : {},
268
+ ...storageStatePath ? { storageState: storageStatePath } : {},
137
269
  viewport: {
138
270
  width: config.viewport.width,
139
271
  height: config.viewport.height
@@ -144,136 +276,437 @@ class BrowserDaemon {
144
276
  page.setDefaultTimeout(3e4);
145
277
  page.setDefaultNavigationTimeout(45e3);
146
278
  const daemon = await BrowserDaemon.initialize({
147
- session: config.session,
279
+ session,
280
+ experiments: args.experiments,
148
281
  externallyManaged: false,
149
282
  browser,
150
283
  context,
151
284
  page,
152
285
  initialPages: [page],
153
- navigateUrl: config.url
286
+ navigateUrl: config.initialUrl
154
287
  });
155
288
  daemon.logger.info("child-launched", {
156
- port: config.port,
289
+ port: config.remoteDebuggingPort,
157
290
  pid: process.pid,
158
- session: config.session
291
+ session
159
292
  });
160
293
  return daemon;
161
294
  }
162
295
  // ── Connect mode ───────────────────────────────────────────────────
163
- static async connectToEndpoint(config) {
296
+ static async connectToEndpoint(args) {
297
+ const { session, browser: config } = args;
164
298
  const browser = await chromium.connectOverCDP(config.cdpEndpoint);
165
299
  const contexts = browser.contexts();
166
300
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
167
301
  const operationalPages = context.pages().filter(isOperationalPage);
168
302
  const page = operationalPages.length > 0 ? operationalPages[operationalPages.length - 1] : await context.newPage();
169
303
  const daemon = await BrowserDaemon.initialize({
170
- session: config.session,
304
+ session,
305
+ experiments: args.experiments,
171
306
  externallyManaged: true,
172
307
  browser,
173
308
  context,
174
309
  page,
175
310
  initialPages: operationalPages.length > 0 ? operationalPages : [page],
176
- navigateUrl: config.url
311
+ navigateUrl: config.initialUrl
177
312
  });
178
313
  daemon.logger.info("child-connected", {
179
314
  cdpEndpoint: config.cdpEndpoint,
180
- url: config.url,
315
+ url: config.initialUrl,
181
316
  pid: process.pid,
182
- session: config.session
317
+ session
183
318
  });
184
319
  return daemon;
185
320
  }
321
+ static async connectToProvider(args) {
322
+ const { session, browser: config } = args;
323
+ const provider = getCloudProviderApi(config.providerName);
324
+ const providerSession = await provider.createSession();
325
+ try {
326
+ const browser = await chromium.connectOverCDP(
327
+ providerSession.cdpEndpoint
328
+ );
329
+ const contexts = browser.contexts();
330
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
331
+ const operationalPages = context.pages().filter(isOperationalPage);
332
+ const page = operationalPages.length > 0 ? operationalPages[operationalPages.length - 1] : await context.newPage();
333
+ const daemon = await BrowserDaemon.initialize({
334
+ session,
335
+ experiments: args.experiments,
336
+ externallyManaged: true,
337
+ browser,
338
+ context,
339
+ page,
340
+ initialPages: operationalPages.length > 0 ? operationalPages : [page],
341
+ navigateUrl: config.initialUrl,
342
+ readyProvider: {
343
+ name: config.providerName,
344
+ sessionId: providerSession.sessionId,
345
+ cdpEndpoint: providerSession.cdpEndpoint,
346
+ liveViewUrl: providerSession.liveViewUrl
347
+ },
348
+ providerSession: {
349
+ provider,
350
+ name: config.providerName,
351
+ sessionId: providerSession.sessionId
352
+ }
353
+ });
354
+ daemon.logger.info("child-provider-connected", {
355
+ provider: config.providerName,
356
+ sessionId: providerSession.sessionId,
357
+ url: config.initialUrl,
358
+ pid: process.pid,
359
+ session
360
+ });
361
+ return daemon;
362
+ } catch (error) {
363
+ await provider.closeSession(providerSession.sessionId);
364
+ throw error;
365
+ }
366
+ }
186
367
  // ── Lifecycle ──────────────────────────────────────────────────────
187
- async shutdown(reason, closeBrowser) {
188
- if (this.shuttingDown) return;
189
- this.shuttingDown = true;
190
- this.logger.info(reason, { session: this.session });
191
- await this.ipcServer.close();
192
- if (!closeBrowser) return;
193
- if (this.externallyManaged) {
194
- try {
195
- this.browser._connection?.close();
196
- } catch {
368
+ registerShutdownHandler(handler) {
369
+ this.shutdownHandlers.push(handler);
370
+ }
371
+ shutdown(reason, closeBrowser, options = {}) {
372
+ if (this.shutdownPromise) return this.shutdownPromise;
373
+ this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
374
+ return this.shutdownPromise;
375
+ }
376
+ async performShutdown(reason, closeBrowser, options) {
377
+ let replayUrl;
378
+ try {
379
+ this.logger.info(reason, { session: this.session });
380
+ for (const handler of this.shutdownHandlers) {
381
+ await handler(options);
382
+ }
383
+ if (closeBrowser) {
384
+ if (this.externallyManaged) {
385
+ try {
386
+ this.browser._connection?.close();
387
+ } catch {
388
+ }
389
+ } else {
390
+ await this.browser.close();
391
+ }
392
+ }
393
+ if (this.providerSession) {
394
+ const result = await this.providerSession.provider.closeSession(
395
+ this.providerSession.sessionId
396
+ );
397
+ replayUrl = result.replayUrl;
398
+ if (result.replayUrl) {
399
+ this.logger.info("provider-recording", {
400
+ session: this.session,
401
+ provider: this.providerSession.name,
402
+ sessionId: this.providerSession.sessionId,
403
+ replayUrl: result.replayUrl
404
+ });
405
+ }
406
+ writeFileSync(
407
+ getSessionProviderClosePath(this.session),
408
+ JSON.stringify(
409
+ {
410
+ provider: this.providerSession.name,
411
+ sessionId: this.providerSession.sessionId,
412
+ replayUrl: result.replayUrl
413
+ },
414
+ null,
415
+ 2
416
+ ),
417
+ "utf8"
418
+ );
419
+ }
420
+ return replayUrl ? { replayUrl } : {};
421
+ } finally {
422
+ if (options.keepIpcClientsAlive) {
423
+ setImmediate(() => {
424
+ for (const cli of this.connectedClis) {
425
+ cli.destroy();
426
+ }
427
+ this.connectedClis.clear();
428
+ });
197
429
  }
198
- } else {
199
- await this.browser.close();
200
430
  }
201
431
  }
202
432
  // ── Page resolution ────────────────────────────────────────────────
203
433
  resolveTargetPage(pageId) {
204
434
  if (!pageId) {
205
- if (this.pageById.size > 1) {
435
+ if (this.page.isClosed()) {
436
+ const openPages = Array.from(this.pageById.values());
437
+ if (openPages.length === 1) return openPages[0];
206
438
  throw new Error(
207
- `Multiple pages are open in session "${this.session}". Pass --page <id> to target a page (run "libretto pages --session ${this.session}" to list ids).`
439
+ `The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`
208
440
  );
209
441
  }
210
- if (this.pageById.size === 1) {
211
- return this.pageById.values().next().value;
212
- }
213
442
  return this.page;
214
443
  }
215
444
  const page = this.pageById.get(pageId);
216
445
  if (!page) {
217
446
  throw new Error(
218
- `Page "${pageId}" was not found in session "${this.session}". Run "libretto pages --session ${this.session}" to list ids.`
447
+ `Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`
219
448
  );
220
449
  }
221
450
  return page;
222
451
  }
223
- // ── IPC handler ────────────────────────────────────────────────────
224
- async handleRequest(request) {
225
- if (request.command === "ping") {
226
- return { protocolVersion: PROTOCOL_VERSION };
452
+ // ── IPC handlers ───────────────────────────────────────────────────
453
+ createIpcHandlers() {
454
+ return {
455
+ ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
456
+ pages: () => this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
457
+ exec: (args) => this.runExec(args),
458
+ readonlyExec: (args) => this.runReadonlyExec(args),
459
+ snapshot: (args) => this.runSnapshot(args),
460
+ getWorkflowStatus: () => this.getWorkflowStatus(),
461
+ resumeWorkflow: () => this.resumeWorkflow(),
462
+ close: () => this.shutdown("ipc-close", true, {
463
+ keepIpcClientsAlive: true
464
+ })
465
+ };
466
+ }
467
+ async runSnapshot(args) {
468
+ if (args.mode === "compact") {
469
+ if (!this.experiments["compact-snapshot-format"]) {
470
+ throw new Error(
471
+ `The compact-snapshot-format experiment is not enabled for session "${this.session}". Close and reopen the session after running ${librettoCommand("experiments enable compact-snapshot-format")}.`
472
+ );
473
+ }
474
+ const targetPage = this.resolveTargetPage(args.pageId);
475
+ const result = await this.withRequestTimeout(
476
+ () => handleCompactSnapshot(
477
+ targetPage,
478
+ this.session,
479
+ this.logger,
480
+ {
481
+ pageId: args.pageId,
482
+ cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
483
+ useCachedSnapshot: args.useCachedSnapshot
484
+ }
485
+ )
486
+ );
487
+ if (!args.useCachedSnapshot) {
488
+ this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
489
+ }
490
+ return result;
227
491
  }
492
+ return this.withRequestTimeout(
493
+ () => handleSnapshot(
494
+ this.resolveTargetPage(args.pageId),
495
+ this.session,
496
+ this.logger,
497
+ args.pageId
498
+ )
499
+ );
500
+ }
501
+ async withRequestTimeout(operation) {
228
502
  let timerId;
229
- return Promise.race([
230
- this.dispatchCommand(request).finally(() => clearTimeout(timerId)),
231
- new Promise((_resolve, reject) => {
232
- timerId = setTimeout(
233
- () => reject(
234
- new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
235
- ),
236
- REQUEST_TIMEOUT_MS
237
- );
238
- })
239
- ]);
503
+ const timeout = new Promise((_resolve, reject) => {
504
+ timerId = setTimeout(
505
+ () => reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
506
+ REQUEST_TIMEOUT_MS
507
+ );
508
+ });
509
+ try {
510
+ return await Promise.race([operation(), timeout]);
511
+ } finally {
512
+ if (timerId) clearTimeout(timerId);
513
+ }
240
514
  }
241
- async dispatchCommand(request) {
242
- switch (request.command) {
243
- case "pages":
244
- return handlePages(this.pageById, this.page);
245
- case "exec":
246
- return handleExec(
247
- this.resolveTargetPage(request.pageId),
248
- request.code,
515
+ async runExec(args) {
516
+ if (this.experiments["compact-snapshot-format"]) {
517
+ return this.runCompactExec(args);
518
+ }
519
+ try {
520
+ const data = await this.withRequestTimeout(
521
+ () => handleExec(
522
+ this.resolveTargetPage(args.pageId),
523
+ args.code,
249
524
  this.context,
250
525
  this.browser,
251
526
  this.execState,
252
527
  this.session,
253
- request.visualize
254
- );
255
- case "readonly-exec":
256
- return handleReadonlyExec(
257
- this.resolveTargetPage(request.pageId),
258
- request.code
259
- );
260
- case "snapshot":
261
- return handleSnapshot(
262
- this.resolveTargetPage(request.pageId),
528
+ args.visualize
529
+ )
530
+ );
531
+ return { ok: true, data };
532
+ } catch (error) {
533
+ return this.createExecErrorResult(error);
534
+ }
535
+ }
536
+ async runCompactExec(args) {
537
+ let targetPage;
538
+ try {
539
+ targetPage = this.resolveTargetPage(args.pageId);
540
+ const page = targetPage;
541
+ const data = await this.withRequestTimeout(async () => {
542
+ const before = this.latestCompactSnapshotByPage.get(page) ?? await snapshot(page);
543
+ const result = await handleExec(
544
+ page,
545
+ args.code,
546
+ this.context,
547
+ this.browser,
548
+ this.execState,
263
549
  this.session,
264
- this.logger,
265
- request.pageId
550
+ args.visualize
266
551
  );
267
- default:
268
- throw new Error(
269
- `Unknown command: ${request.command}`
552
+ try {
553
+ const waitResult = await waitForPageStable(page);
554
+ if (!waitResult.ok) {
555
+ this.logger.warn("compact-exec-stability-wait-incomplete", {
556
+ session: this.session,
557
+ pageId: args.pageId,
558
+ diagnostics: waitResult.diagnostics
559
+ });
560
+ }
561
+ const after = await snapshot(page);
562
+ const snapshotDiff = diffSnapshots(before, after);
563
+ this.latestCompactSnapshotByPage.set(page, after);
564
+ return { ...result, snapshotDiff };
565
+ } catch (error) {
566
+ this.latestCompactSnapshotByPage.delete(page);
567
+ this.logger.warn("compact-exec-diff-failed", {
568
+ session: this.session,
569
+ pageId: args.pageId,
570
+ error: error instanceof Error ? error.message : String(error)
571
+ });
572
+ return result;
573
+ }
574
+ });
575
+ return { ok: true, data };
576
+ } catch (error) {
577
+ if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
578
+ return this.createExecErrorResult(error);
579
+ }
580
+ }
581
+ async runReadonlyExec(args) {
582
+ try {
583
+ const data = await this.withRequestTimeout(
584
+ () => handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code)
585
+ );
586
+ return { ok: true, data };
587
+ } catch (error) {
588
+ return this.createExecErrorResult(error);
589
+ }
590
+ }
591
+ createExecErrorResult(error) {
592
+ return {
593
+ ok: false,
594
+ message: error instanceof Error ? error.message : String(error),
595
+ output: error instanceof Error ? error.output : void 0
596
+ };
597
+ }
598
+ startWorkflow(args) {
599
+ if (this.workflowController) {
600
+ throw new Error("Workflow controller has already started.");
601
+ }
602
+ this.workflowController = new WorkflowController({
603
+ session: this.session,
604
+ headed: args.headed,
605
+ page: this.page,
606
+ context: this.context,
607
+ logger: this.logger,
608
+ onLog: (event) => {
609
+ this.broadcast("workflowOutput", event);
610
+ },
611
+ onOutcome: (outcome) => {
612
+ if (outcome.state === "paused") {
613
+ this.broadcast("workflowPaused", {
614
+ pausedAt: outcome.pausedAt,
615
+ url: outcome.url
616
+ });
617
+ return;
618
+ }
619
+ this.broadcast(
620
+ "workflowFinished",
621
+ outcome.result === "completed" ? { result: "completed", completedAt: outcome.completedAt } : {
622
+ result: "failed",
623
+ message: outcome.message,
624
+ phase: outcome.phase
625
+ }
270
626
  );
627
+ }
628
+ });
629
+ this.workflowController.start({
630
+ integrationPath: args.workflow.integrationPath,
631
+ params: args.workflow.params,
632
+ visualize: args.workflow.visualize,
633
+ loadedWorkflow: args.loadedWorkflow
634
+ });
635
+ }
636
+ getWorkflowStatus() {
637
+ return this.workflowController?.getStatus() ?? { state: "idle" };
638
+ }
639
+ resumeWorkflow() {
640
+ if (!this.workflowController) {
641
+ throw new Error("Workflow is not paused.");
642
+ }
643
+ this.workflowController.resume();
644
+ }
645
+ async broadcast(name, message) {
646
+ const results = await Promise.allSettled(
647
+ Array.from(this.connectedClis, (cli) => {
648
+ const call = cli.call[name];
649
+ return call(message);
650
+ })
651
+ );
652
+ for (const result of results) {
653
+ if (result.status === "rejected") {
654
+ this.logger.warn("workflow-event-failed", {
655
+ event: name,
656
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
657
+ });
658
+ }
271
659
  }
272
660
  }
273
661
  }
274
662
  async function main() {
275
663
  const config = JSON.parse(process.argv[2]);
276
- const daemon = isConnectConfig(config) ? await BrowserDaemon.connectToEndpoint(config) : await BrowserDaemon.launchBrowser(config);
664
+ const headed = config.browser.kind === "launch" ? config.browser.headed : false;
665
+ let loadedWorkflow;
666
+ if (config.workflow) {
667
+ try {
668
+ loadedWorkflow = await loadDefaultWorkflow(
669
+ getAbsoluteIntegrationPath(config.workflow.integrationPath)
670
+ );
671
+ } catch (error) {
672
+ throw new UserFacingStartupError(
673
+ error instanceof Error ? error.message : String(error)
674
+ );
675
+ }
676
+ }
677
+ const daemon = config.browser.kind === "provider" ? await BrowserDaemon.connectToProvider({
678
+ session: config.session,
679
+ experiments: config.experiments,
680
+ browser: config.browser
681
+ }) : config.browser.kind === "connect" ? await BrowserDaemon.connectToEndpoint({
682
+ session: config.session,
683
+ experiments: config.experiments,
684
+ browser: config.browser
685
+ }) : await BrowserDaemon.launchBrowser({
686
+ session: config.session,
687
+ experiments: config.experiments,
688
+ browser: config.browser,
689
+ workflow: config.workflow
690
+ });
691
+ if (config.workflow) {
692
+ void waitForSessionState(config.session).then(
693
+ () => daemon.startWorkflow({
694
+ workflow: config.workflow,
695
+ headed,
696
+ loadedWorkflow
697
+ })
698
+ ).catch((error) => {
699
+ const message = error instanceof Error ? error.message : String(error);
700
+ daemon.logger.error("workflow-failed", {
701
+ error: message
702
+ });
703
+ return daemon.broadcast("workflowFinished", {
704
+ result: "failed",
705
+ message,
706
+ phase: "setup"
707
+ }).finally(() => daemon.shutdown("workflow-start-failed", true));
708
+ });
709
+ }
277
710
  process.on("SIGTERM", () => {
278
711
  void daemon.shutdown("child-sigterm", true);
279
712
  });
@@ -295,4 +728,17 @@ async function main() {
295
728
  });
296
729
  });
297
730
  }
298
- await main();
731
+ function reportStartupError(error) {
732
+ if (error instanceof UserFacingStartupError) {
733
+ process.send?.({
734
+ type: "startup-error",
735
+ message: error.message
736
+ });
737
+ }
738
+ process.exit(1);
739
+ }
740
+ try {
741
+ await main();
742
+ } catch (error) {
743
+ reportStartupError(error);
744
+ }