libretto 0.6.11 → 0.6.13

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