libretto 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,17 +9,21 @@ pnpm add libretto playwright zod
9
9
  npx libretto init
10
10
  ```
11
11
 
12
- > **pnpm users:** add `onlyBuiltDependencies` to your `package.json` to allow
13
- > Playwright's postinstall script to run:
12
+ > **pnpm users:** if your workspace uses `onlyBuiltDependencies`, add both
13
+ > `libretto` and `playwright` to allow their postinstall scripts to run
14
+ > (libretto's postinstall copies skill files and installs Playwright Chromium):
14
15
  >
15
16
  > ```jsonc
16
17
  > // package.json
17
18
  > {
18
19
  > "pnpm": {
19
- > "onlyBuiltDependencies": ["playwright"]
20
+ > "onlyBuiltDependencies": ["libretto", "playwright"]
20
21
  > }
21
22
  > }
22
23
  > ```
24
+ >
25
+ > If the postinstall was skipped (e.g., `libretto` wasn't in the allowlist),
26
+ > run `npx libretto init` manually after install to complete setup.
23
27
 
24
28
  ## Quick Start
25
29
 
@@ -119,6 +123,33 @@ Run `npx libretto help` for the full list.
119
123
  | `libretto/run` | `launchBrowser` |
120
124
  | `libretto/state` | Session state serialization and parsing |
121
125
 
126
+ ## Using Recovery Helpers
127
+
128
+ The recovery module (`libretto/recovery`) provides `detectSubmissionError` and
129
+ `executeRecoveryAgent` for handling form submission errors. Both accept an
130
+ `LLMClient` — create one with `createLLMClientFromModel` and pass it directly:
131
+
132
+ ```typescript
133
+ import { detectSubmissionError, executeRecoveryAgent } from "libretto/recovery";
134
+ import { createLLMClientFromModel } from "libretto/llm";
135
+ import { openai } from "@ai-sdk/openai";
136
+
137
+ const llmClient = createLLMClientFromModel(openai("gpt-4o"));
138
+
139
+ // Detect if a submission produced an error
140
+ const error = await detectSubmissionError(
141
+ page, submissionError, "eligibility check failed", llmClient, knownErrors, logger,
142
+ );
143
+
144
+ // Or run the full recovery agent to retry with corrections
145
+ const result = await executeRecoveryAgent(
146
+ page, error, llmClient, recoveryOptions, logger,
147
+ );
148
+ ```
149
+
150
+ No need to write custom wrappers — `createLLMClientFromModel` bridges any
151
+ Vercel AI SDK provider into the `LLMClient` interface that recovery helpers expect.
152
+
122
153
  ## Links
123
154
 
124
155
  - [GitHub](https://github.com/saffron-health/libretto)
package/dist/cli/cli.js CHANGED
@@ -39,7 +39,7 @@ Commands:
39
39
  init [--skip-browsers] Initialize libretto (copy skills, install browsers)
40
40
  open <url> [--headless] Launch browser and open URL (headed by default)
41
41
  Automatically loads saved profile if available
42
- run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--headed|--headless] [--debug] Run an exported Libretto workflow from a file; pass --debug to enable pause-on-failure debugging (or --no-debug to disable)
42
+ run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--headed|--headless] Run an exported Libretto workflow from a file
43
43
  ai configure [preset] [-- <command prefix...>] Configure AI runtime for analysis commands
44
44
  save <url|domain> Save current browser session (cookies, localStorage, etc.)
45
45
  exec <code> [--visualize] Execute Playwright typescript code (--visualize enables ghost cursor + highlight)
@@ -48,7 +48,7 @@ Commands:
48
48
  actions [--last N] [--filter regex] [--action TYPE] [--source SOURCE] [--clear] View captured actions
49
49
  pages List open pages in the active session
50
50
  resume Resume a paused workflow in the active session
51
- close Close the browser
51
+ close [--all] [--force] Close the browser for the session, or all tracked sessions with --all
52
52
 
53
53
  Options:
54
54
  --session <name> Use a named session (default: "default")
@@ -71,6 +71,8 @@ Examples:
71
71
  libretto-cli snapshot --objective "Find the submit button" --context "Submitting a referral form, already filled in patient details"
72
72
  libretto-cli resume --session default
73
73
  libretto-cli close
74
+ libretto-cli close --all
75
+ libretto-cli close --all --force
74
76
 
75
77
  # Multiple sessions
76
78
  libretto-cli open https://site1.com --session test1
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  runClose as runCloseWithLogger,
3
+ runCloseAll as runCloseAllWithLogger,
3
4
  runOpen,
4
5
  runPages,
5
6
  runSave
@@ -44,9 +45,31 @@ function registerBrowserCommands(yargs, logger) {
44
45
  }
45
46
  ).command("pages", "List open pages in the session", (cmd) => cmd, async (argv) => {
46
47
  await runPages(String(argv.session), logger);
47
- }).command("close", "Close the browser", (cmd) => cmd, async (argv) => {
48
- await runCloseWithLogger(String(argv.session), logger);
49
- });
48
+ }).command(
49
+ "close",
50
+ "Close the browser",
51
+ (cmd) => cmd.option("all", {
52
+ type: "boolean",
53
+ default: false,
54
+ describe: "Close all tracked sessions in this workspace"
55
+ }).option("force", {
56
+ type: "boolean",
57
+ default: false,
58
+ describe: "Force kill sessions that ignore SIGTERM (requires --all)"
59
+ }),
60
+ async (argv) => {
61
+ const closeAll = Boolean(argv.all);
62
+ const force = Boolean(argv.force);
63
+ if (force && !closeAll) {
64
+ throw new Error("Usage: libretto-cli close --all [--force]");
65
+ }
66
+ if (closeAll) {
67
+ await runCloseAllWithLogger(logger, { force });
68
+ return;
69
+ }
70
+ await runCloseWithLogger(String(argv.session), logger);
71
+ }
72
+ );
50
73
  }
51
74
  async function runClose(session) {
52
75
  await withSessionLogger(session, async (logger) => {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { fork } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import * as moduleBuiltin from "node:module";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { installInstrumentation } from "../../shared/instrumentation/index.js";
@@ -10,6 +10,8 @@ import {
10
10
  import { getPauseSignalPaths } from "../core/pause-signals.js";
11
11
  import {
12
12
  assertSessionAvailableForStart,
13
+ clearSessionState,
14
+ readSessionState,
13
15
  readSessionStateOrThrow,
14
16
  setSessionStatus
15
17
  } from "../core/session.js";
@@ -170,6 +172,35 @@ function isProcessRunning(pid) {
170
172
  return false;
171
173
  }
172
174
  }
175
+ async function stopExistingFailedRunSession(session, logger) {
176
+ const existingState = readSessionState(session, logger);
177
+ if (!existingState || existingState.status !== "failed") {
178
+ return;
179
+ }
180
+ logger.info("run-release-existing-failed-session", {
181
+ session,
182
+ pid: existingState.pid,
183
+ port: existingState.port
184
+ });
185
+ clearSessionState(session, logger);
186
+ const stopDeadline = Date.now() + 3e3;
187
+ while (isProcessRunning(existingState.pid) && Date.now() < stopDeadline) {
188
+ await new Promise((resolveWait) => setTimeout(resolveWait, 100));
189
+ }
190
+ if (isProcessRunning(existingState.pid)) {
191
+ logger.warn("run-release-existing-failed-session-timeout", {
192
+ session,
193
+ pid: existingState.pid
194
+ });
195
+ console.warn(
196
+ `Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still shutting down; continuing.`
197
+ );
198
+ return;
199
+ }
200
+ console.log(
201
+ `Closed existing failed workflow process for session "${session}" (pid ${existingState.pid}).`
202
+ );
203
+ }
173
204
  function readJsonFileIfExists(path) {
174
205
  if (!existsSync(path)) return null;
175
206
  try {
@@ -246,7 +277,7 @@ async function runResume(session, logger) {
246
277
  } = getPauseSignalPaths(session);
247
278
  if (!existsSync(pausedSignalPath)) {
248
279
  throw new Error(
249
- `Session "${session}" is not paused. Run "libretto-cli run ... --session ${session}" and call ctx.pause() first.`
280
+ `Session "${session}" is not paused. Run "libretto-cli run ... --session ${session}" and call pause() first.`
250
281
  );
251
282
  }
252
283
  if (!isProcessRunning(state.pid)) {
@@ -297,6 +328,7 @@ async function runResume(session, logger) {
297
328
  console.log("Workflow paused.");
298
329
  }
299
330
  async function runIntegrationFromFile(args, logger) {
331
+ await stopExistingFailedRunSession(args.session, logger);
300
332
  assertSessionAvailableForStart(args.session, logger);
301
333
  const signalPaths = getPauseSignalPaths(args.session);
302
334
  clearSignalIfExists(signalPaths.pausedSignalPath);
@@ -308,12 +340,11 @@ async function runIntegrationFromFile(args, logger) {
308
340
  new URL("../workers/run-integration-worker.js", import.meta.url)
309
341
  );
310
342
  const payload = JSON.stringify(args);
311
- const worker = fork(workerEntryPath, [payload], {
343
+ const worker = spawn(process.execPath, [workerEntryPath, payload], {
312
344
  detached: true,
313
- stdio: ["ignore", "ignore", "ignore", "ipc"],
345
+ stdio: "ignore",
314
346
  env: process.env
315
347
  });
316
- worker.disconnect();
317
348
  worker.unref();
318
349
  const outcome = await waitForWorkflowOutcome({
319
350
  session: args.session,
@@ -326,7 +357,10 @@ async function runIntegrationFromFile(args, logger) {
326
357
  }
327
358
  if (outcome.status === "failed") {
328
359
  setSessionStatus(args.session, "failed", logger);
329
- throw new Error(outcome.message ?? "Workflow failed during run.");
360
+ throw new Error(
361
+ `${outcome.message ?? "Workflow failed during run."}
362
+ Browser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-run the workflow.`
363
+ );
330
364
  }
331
365
  if (outcome.status === "exited") {
332
366
  setSessionStatus(args.session, "exited", logger);
@@ -360,11 +394,17 @@ function registerExecutionCommands(yargs, logger) {
360
394
  ).command(
361
395
  "run [integrationFile] [integrationExport]",
362
396
  "Run an exported Libretto workflow from a file",
363
- (cmd) => cmd.option("params", { type: "string" }).option("params-file", { type: "string" }).option("headed", { type: "boolean", default: false }).option("headless", { type: "boolean", default: false }).option("debug", { type: "boolean" }),
397
+ (cmd) => cmd.option("params", { type: "string" }).option("params-file", { type: "string" }).option("headed", { type: "boolean", default: false }).option("headless", { type: "boolean", default: false }).option("auth-profile", { type: "string", describe: "Domain for local auth profile (e.g. apps.example.com)" }),
364
398
  async (argv) => {
365
- const usage = "Usage: libretto-cli run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--headed|--headless] [--debug]";
399
+ const usage = "Usage: libretto-cli run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--headed|--headless]";
366
400
  const integrationPath = argv.integrationFile;
367
401
  const exportName = argv.integrationExport;
402
+ const legacyDebug = argv.debug;
403
+ if (legacyDebug !== void 0) {
404
+ throw new Error(
405
+ "The --debug flag has been removed. Run the command without --debug."
406
+ );
407
+ }
368
408
  if (!integrationPath || !exportName) {
369
409
  throw new Error(usage);
370
410
  }
@@ -397,15 +437,14 @@ function registerExecutionCommands(yargs, logger) {
397
437
  throw new Error("Cannot pass both --headed and --headless.");
398
438
  }
399
439
  const headlessMode = hasHeadedFlag ? false : hasHeadlessFlag ? true : void 0;
400
- const debugFlag = argv.debug;
401
- const debugMode = debugFlag !== void 0 ? debugFlag : process.env.LIBRETTO_DEBUG === "true";
440
+ const authProfileDomain = argv["auth-profile"];
402
441
  await runIntegrationFromFile({
403
442
  integrationPath,
404
443
  exportName,
405
444
  session,
406
445
  params,
407
446
  headless: headlessMode ?? false,
408
- debug: debugMode
447
+ authProfileDomain
409
448
  }, logger);
410
449
  }
411
450
  ).command(
@@ -12,12 +12,15 @@ import {
12
12
  import {
13
13
  assertSessionAvailableForStart,
14
14
  clearSessionState,
15
+ listSessionsWithStateFile,
15
16
  readSessionStateOrThrow,
16
17
  logFileForSession,
17
18
  readSessionState,
18
19
  writeSessionState
19
20
  } from "./session.js";
20
21
  import { installSessionTelemetry } from "./session-telemetry.js";
22
+ const CLOSE_WAIT_MS = 1500;
23
+ const FORCE_CLOSE_WAIT_MS = 300;
21
24
  async function pickFreePort() {
22
25
  return await new Promise((resolve2, reject) => {
23
26
  const server = createServer();
@@ -488,16 +491,137 @@ async function runClose(session, logger) {
488
491
  return;
489
492
  }
490
493
  logger.info("close-killing", { session, pid: state.pid, port: state.port });
491
- try {
492
- process.kill(state.pid, "SIGTERM");
493
- } catch (err) {
494
- logger.warn("close-kill-failed", { error: err, session, pid: state.pid });
495
- }
496
- await new Promise((r) => setTimeout(r, 1500));
494
+ sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
495
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
497
496
  clearSessionState(session, logger);
498
497
  logger.info("close-success", { session });
499
498
  console.log(`Browser closed (session: ${session}).`);
500
499
  }
500
+ function waitForCloseSignalWindow(ms) {
501
+ return new Promise((r) => setTimeout(r, ms));
502
+ }
503
+ function isPidRunning(pid) {
504
+ try {
505
+ process.kill(pid, 0);
506
+ return true;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+ function sendSignalToProcessGroupOrPid(pid, signal, logger, session) {
512
+ try {
513
+ process.kill(pid, signal);
514
+ logger.info("close-signal-pid", { session, pid, signal });
515
+ } catch (pidErr) {
516
+ const pidCode = pidErr.code;
517
+ if (pidCode !== "ESRCH") {
518
+ logger.warn("close-signal-pid-failed", {
519
+ session,
520
+ pid,
521
+ signal,
522
+ error: pidErr
523
+ });
524
+ }
525
+ }
526
+ }
527
+ function formatSessionList(targets) {
528
+ return targets.map((target) => `"${target.session}"`).join(", ");
529
+ }
530
+ function resolveClosableSessions(logger) {
531
+ const sessions = listSessionsWithStateFile();
532
+ const closable = [];
533
+ let clearedUnreadableStates = 0;
534
+ for (const session of sessions) {
535
+ const state = readSessionState(session, logger);
536
+ if (!state) {
537
+ clearSessionState(session, logger);
538
+ clearedUnreadableStates += 1;
539
+ continue;
540
+ }
541
+ closable.push({
542
+ session,
543
+ pid: state.pid,
544
+ port: state.port
545
+ });
546
+ }
547
+ return { closable, clearedUnreadableStates };
548
+ }
549
+ function clearStoppedSessionStates(sessions, logger) {
550
+ let cleared = 0;
551
+ for (const session of sessions) {
552
+ if (!isPidRunning(session.pid)) {
553
+ clearSessionState(session.session, logger);
554
+ cleared += 1;
555
+ }
556
+ }
557
+ return cleared;
558
+ }
559
+ async function runCloseAll(logger, options) {
560
+ const force = Boolean(options?.force);
561
+ logger.info("close-all-start", { force });
562
+ const { closable, clearedUnreadableStates } = resolveClosableSessions(logger);
563
+ if (closable.length === 0) {
564
+ if (clearedUnreadableStates > 0) {
565
+ console.log(
566
+ `Cleared ${clearedUnreadableStates} unreadable session state file(s).`
567
+ );
568
+ }
569
+ console.log("No browser sessions found.");
570
+ return;
571
+ }
572
+ for (const target of closable) {
573
+ logger.info("close-all-sigterm", {
574
+ session: target.session,
575
+ pid: target.pid,
576
+ port: target.port
577
+ });
578
+ sendSignalToProcessGroupOrPid(target.pid, "SIGTERM", logger, target.session);
579
+ }
580
+ await waitForCloseSignalWindow(CLOSE_WAIT_MS);
581
+ let survivors = closable.filter((target) => isPidRunning(target.pid));
582
+ if (survivors.length > 0 && !force) {
583
+ const closed = clearStoppedSessionStates(closable, logger);
584
+ throw new Error(
585
+ [
586
+ `Failed to close ${survivors.length} session(s) gracefully: ${formatSessionList(survivors)}.`,
587
+ `Closed ${closed} session(s).`,
588
+ "Retry with: libretto-cli close --all --force"
589
+ ].join("\n")
590
+ );
591
+ }
592
+ let forceKilled = 0;
593
+ if (survivors.length > 0) {
594
+ for (const survivor of survivors) {
595
+ logger.warn("close-all-sigkill", {
596
+ session: survivor.session,
597
+ pid: survivor.pid
598
+ });
599
+ sendSignalToProcessGroupOrPid(survivor.pid, "SIGKILL", logger, survivor.session);
600
+ forceKilled += 1;
601
+ }
602
+ await waitForCloseSignalWindow(FORCE_CLOSE_WAIT_MS);
603
+ survivors = survivors.filter((target) => isPidRunning(target.pid));
604
+ if (survivors.length > 0) {
605
+ const closed = clearStoppedSessionStates(closable, logger);
606
+ throw new Error(
607
+ [
608
+ `Failed to force-close ${survivors.length} session(s): ${formatSessionList(survivors)}.`,
609
+ `Closed ${closed} session(s).`
610
+ ].join("\n")
611
+ );
612
+ }
613
+ }
614
+ clearStoppedSessionStates(closable, logger);
615
+ if (clearedUnreadableStates > 0) {
616
+ console.log(
617
+ `Cleared ${clearedUnreadableStates} unreadable session state file(s).`
618
+ );
619
+ }
620
+ console.log(`Closed ${closable.length} session(s).`);
621
+ if (forceKilled > 0) {
622
+ console.log(`Force-killed ${forceKilled} session(s).`);
623
+ }
624
+ }
501
625
  function resolvePath(filePath) {
502
626
  return join(process.cwd(), filePath);
503
627
  }
@@ -517,6 +641,7 @@ export {
517
641
  normalizeUrl,
518
642
  resolvePath,
519
643
  runClose,
644
+ runCloseAll,
520
645
  runOpen,
521
646
  runPages,
522
647
  runSave
@@ -14,7 +14,6 @@ import {
14
14
  } from "./context.js";
15
15
  import {
16
16
  SESSION_STATE_VERSION,
17
- SessionStatusSchema,
18
17
  parseSessionStateContent,
19
18
  serializeSessionState
20
19
  } from "../../shared/state/index.js";
@@ -65,11 +64,19 @@ function readSessionState(session, logger) {
65
64
  return null;
66
65
  }
67
66
  }
68
- function listActiveSessions() {
67
+ function listSessionsWithStateFile() {
69
68
  if (!existsSync(LIBRETTO_SESSIONS_DIR)) return [];
70
- return readdirSync(LIBRETTO_SESSIONS_DIR).filter(
71
- (session) => existsSync(getSessionStatePath(session))
72
- );
69
+ return readdirSync(LIBRETTO_SESSIONS_DIR).filter((session) => {
70
+ try {
71
+ validateSessionName(session);
72
+ } catch {
73
+ return false;
74
+ }
75
+ return existsSync(getSessionStatePath(session));
76
+ }).sort();
77
+ }
78
+ function listActiveSessions() {
79
+ return listSessionsWithStateFile();
73
80
  }
74
81
  function throwSessionNotFoundError(session) {
75
82
  const active = listActiveSessions();
@@ -128,9 +135,6 @@ function clearSessionState(session, logger) {
128
135
  unlinkSync(stateFile);
129
136
  logger?.info("session-state-cleared", { session, stateFile });
130
137
  }
131
- function isSessionStatus(value) {
132
- return SessionStatusSchema.safeParse(value).success;
133
- }
134
138
  function isPidRunning(pid) {
135
139
  try {
136
140
  process.kill(pid, 0);
@@ -151,11 +155,6 @@ function setSessionStatus(session, status, logger) {
151
155
  function assertSessionAvailableForStart(session, logger) {
152
156
  const existingState = readSessionState(session, logger);
153
157
  if (!existingState) return;
154
- if (isSessionStatus(existingState.status)) {
155
- if (existingState.status === "completed" || existingState.status === "failed" || existingState.status === "exited") {
156
- return;
157
- }
158
- }
159
158
  if (!isPidRunning(existingState.pid)) {
160
159
  setSessionStatus(session, "exited", logger);
161
160
  return;
@@ -174,6 +173,7 @@ export {
174
173
  assertSessionStateExistsOrThrow,
175
174
  clearSessionState,
176
175
  getStateFilePath,
176
+ listSessionsWithStateFile,
177
177
  logFileForSession,
178
178
  readSessionState,
179
179
  readSessionStateOrThrow,