libretto 0.6.11 → 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,6 +2,12 @@ import type { Page } from "playwright";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import type { LoggerApi } from "../../../shared/logger/index.js";
4
4
  import { getSessionSnapshotRunDir } from "../context.js";
5
+ import {
6
+ snapshot,
7
+ type Snapshot,
8
+ } from "../../../shared/snapshot/capture-snapshot.js";
9
+ import { waitForPageStable } from "../../../shared/snapshot/wait-for-page-stable.js";
10
+ import { librettoCommand } from "../../../shared/package-manager.js";
5
11
  import {
6
12
  resolveSnapshotViewport,
7
13
  readSnapshotViewportMetrics,
@@ -12,6 +18,13 @@ import {
12
18
 
13
19
  const RENDER_SETTLE_TIMEOUT_MS = 10_000;
14
20
 
21
+ type SnapshotScreenshot = {
22
+ pngPath: string;
23
+ snapshotRunId: string;
24
+ pageUrl: string;
25
+ title: string;
26
+ };
27
+
15
28
  export async function handleSnapshot(
16
29
  targetPage: Page,
17
30
  session: string,
@@ -24,6 +37,98 @@ export async function handleSnapshot(
24
37
  pageUrl: string;
25
38
  title: string;
26
39
  }> {
40
+ const screenshot = await captureSnapshotScreenshot(
41
+ targetPage,
42
+ session,
43
+ logger,
44
+ pageId,
45
+ );
46
+ const htmlPath = `${getSessionSnapshotRunDir(
47
+ session,
48
+ screenshot.snapshotRunId,
49
+ )}/page.html`;
50
+
51
+ // Capture HTML content.
52
+ const htmlContent = await targetPage.content();
53
+ writeFileSync(htmlPath, htmlContent);
54
+
55
+ logger.info("screenshot-success", {
56
+ session,
57
+ pageUrl: screenshot.pageUrl,
58
+ title: screenshot.title,
59
+ pngPath: screenshot.pngPath,
60
+ htmlPath,
61
+ snapshotRunId: screenshot.snapshotRunId,
62
+ });
63
+
64
+ return {
65
+ ...screenshot,
66
+ htmlPath,
67
+ };
68
+ }
69
+
70
+ export async function handleCompactSnapshot(
71
+ targetPage: Page,
72
+ session: string,
73
+ logger: LoggerApi,
74
+ options: {
75
+ pageId?: string;
76
+ cachedSnapshot?: Snapshot | null;
77
+ useCachedSnapshot?: boolean;
78
+ } = {},
79
+ ): Promise<{
80
+ mode: "compact";
81
+ pngPath: string;
82
+ snapshot: Snapshot;
83
+ }> {
84
+ if (options.useCachedSnapshot) {
85
+ if (!options.cachedSnapshot) {
86
+ throw new Error(
87
+ `No compact snapshot is cached for session "${session}". Run ${librettoCommand(`snapshot --session ${session}`)} first.`,
88
+ );
89
+ }
90
+ const screenshot = await captureSnapshotScreenshot(
91
+ targetPage,
92
+ session,
93
+ logger,
94
+ options.pageId,
95
+ );
96
+ return {
97
+ mode: "compact",
98
+ pngPath: screenshot.pngPath,
99
+ snapshot: options.cachedSnapshot,
100
+ };
101
+ }
102
+
103
+ const waitResult = await waitForPageStable(targetPage);
104
+ if (!waitResult.ok) {
105
+ logger.warn("compact-snapshot-stability-wait-incomplete", {
106
+ session,
107
+ pageId: options.pageId,
108
+ diagnostics: waitResult.diagnostics,
109
+ });
110
+ }
111
+
112
+ const screenshot = await captureSnapshotScreenshot(
113
+ targetPage,
114
+ session,
115
+ logger,
116
+ options.pageId,
117
+ );
118
+
119
+ return {
120
+ mode: "compact",
121
+ pngPath: screenshot.pngPath,
122
+ snapshot: await snapshot(targetPage),
123
+ };
124
+ }
125
+
126
+ async function captureSnapshotScreenshot(
127
+ targetPage: Page,
128
+ session: string,
129
+ logger: LoggerApi,
130
+ pageId?: string,
131
+ ): Promise<SnapshotScreenshot> {
27
132
  const snapshotRunId = `snapshot-${Date.now()}`;
28
133
  const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
29
134
  mkdirSync(snapshotRunDir, { recursive: true });
@@ -45,7 +150,6 @@ export async function handleSnapshot(
45
150
  }
46
151
 
47
152
  const pngPath = `${snapshotRunDir}/page.png`;
48
- const htmlPath = `${snapshotRunDir}/page.html`;
49
153
 
50
154
  // Wait for network to settle before capturing.
51
155
  await Promise.race([
@@ -91,22 +195,16 @@ export async function handleSnapshot(
91
195
  await targetPage.screenshot({ path: pngPath });
92
196
  }
93
197
 
94
- // Capture HTML content.
95
- const htmlContent = await targetPage.content();
96
- writeFileSync(htmlPath, htmlContent);
97
-
98
- logger.info("screenshot-success", {
198
+ logger.info("screenshot-captured", {
99
199
  session,
100
200
  pageUrl,
101
201
  title,
102
202
  pngPath,
103
- htmlPath,
104
203
  snapshotRunId,
105
204
  });
106
205
 
107
206
  return {
108
207
  pngPath,
109
- htmlPath,
110
208
  snapshotRunId,
111
209
  pageUrl: pageUrl ?? "",
112
210
  title: title ?? "",
@@ -0,0 +1,85 @@
1
+ import {
2
+ readLibrettoConfig,
3
+ type LibrettoConfig,
4
+ writeLibrettoConfig,
5
+ } from "./config.js";
6
+
7
+ export const EXPERIMENTS = {
8
+ "compact-snapshot-format": {
9
+ title: "Compact snapshot format",
10
+ oneSentenceDescription:
11
+ "Use compact accessibility snapshots and exec page-change diffs without an AI sub-agent.",
12
+ docs: [
13
+ "Compact snapshot format changes how agents should use snapshot and exec after the experiment is enabled.",
14
+ "",
15
+ "Compared with the skill's documented behavior:",
16
+ " - Run libretto snapshot --session <name> without --objective or --context.",
17
+ " - Snapshot output is a screenshot path plus a compact accessibility tree; it does not use the PNG + HTML + AI analysis path.",
18
+ " - Run libretto snapshot <ref> --session <name> to inspect a subtree from the latest full compact snapshot.",
19
+ " - Run libretto exec normally; after successful mutations, Libretto prints page-change diffs from compact snapshots without AI analysis.",
20
+ " - If a session was already open before enabling the experiment, close and reopen it before relying on this behavior.",
21
+ "",
22
+ "Full compact snapshot:",
23
+ " libretto snapshot --session <name>",
24
+ "",
25
+ "Cached subtree snapshot:",
26
+ " libretto snapshot <ref> --session <name>",
27
+ "",
28
+ "Run an unscoped snapshot before using refs. Subtree snapshots capture a fresh screenshot but reuse the latest cached tree.",
29
+ "",
30
+ "Notes:",
31
+ " - Use ref forms printed in the tree, such as l16. Numeric-suffix aliases such as e16 also match l16.",
32
+ ].join("\n"),
33
+ defaultValue: false,
34
+ },
35
+ } as const satisfies Record<
36
+ string,
37
+ {
38
+ title: string;
39
+ oneSentenceDescription: string;
40
+ docs?: string;
41
+ defaultValue: boolean;
42
+ }
43
+ >;
44
+
45
+ export type ExperimentName = keyof typeof EXPERIMENTS;
46
+ export type Experiments = Record<ExperimentName, boolean>;
47
+
48
+ export function isExperimentName(name: string): name is ExperimentName {
49
+ return Object.hasOwn(EXPERIMENTS, name);
50
+ }
51
+
52
+ export function resolveExperiments(
53
+ config: LibrettoConfig = readLibrettoConfig(),
54
+ ): Experiments {
55
+ return Object.fromEntries(
56
+ Object.entries(EXPERIMENTS).map(([name, metadata]) => [
57
+ name,
58
+ config.experiments?.[name] ?? metadata.defaultValue,
59
+ ]),
60
+ ) as Experiments;
61
+ }
62
+
63
+ export function setExperimentEnabled(
64
+ name: string,
65
+ enabled: boolean,
66
+ configPath?: string,
67
+ ): Experiments {
68
+ if (!isExperimentName(name)) {
69
+ throw new Error(`Unknown experiment "${name}".`);
70
+ }
71
+
72
+ const config = readLibrettoConfig(configPath);
73
+ const writtenConfig = writeLibrettoConfig(
74
+ {
75
+ ...config,
76
+ experiments: {
77
+ ...config.experiments,
78
+ [name]: enabled,
79
+ },
80
+ },
81
+ configPath,
82
+ );
83
+
84
+ return resolveExperiments(writtenConfig);
85
+ }
@@ -22,6 +22,7 @@ import {
22
22
  type SessionStatus,
23
23
  type SessionState,
24
24
  } from "../../shared/state/index.js";
25
+ import { librettoCommand } from "../../shared/package-manager.js";
25
26
 
26
27
  const SESSION_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
27
28
 
@@ -154,7 +155,7 @@ function throwSessionNotFoundError(session: string): never {
154
155
  }
155
156
  lines.push("");
156
157
  lines.push("Start one with:");
157
- lines.push(` libretto open <url> --session ${session}`);
158
+ lines.push(` ${librettoCommand(`open <url> --session ${session}`)}`);
158
159
  throw new Error(lines.join("\n"));
159
160
  }
160
161
 
@@ -230,7 +231,7 @@ export function assertSessionAllowsCommand(
230
231
 
231
232
  const supportedModes = [...allowedModes].join(", ");
232
233
  throw new Error(
233
- `Command "${commandName}" is blocked for session "${state.session}" because it is in ${mode} mode. Allowed modes for this command: ${supportedModes}. Run \`libretto session-mode write-access --session ${state.session}\` to unlock the session.`,
234
+ `Command "${commandName}" is blocked for session "${state.session}" because it is in ${mode} mode. Allowed modes for this command: ${supportedModes}. Run \`${librettoCommand(`session-mode write-access --session ${state.session}`)}\` to unlock the session.`,
234
235
  );
235
236
  }
236
237
 
@@ -281,7 +282,7 @@ export function assertSessionAvailableForStart(
281
282
  // if they have a provider field with a cdpEndpoint.
282
283
  if (existingState.provider && existingState.cdpEndpoint) {
283
284
  throw new Error(
284
- `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: libretto close --session ${session}`,
285
+ `Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: ${librettoCommand(`close --session ${session}`)}`,
285
286
  );
286
287
  }
287
288
 
@@ -291,6 +292,6 @@ export function assertSessionAvailableForStart(
291
292
  }
292
293
  const endpoint = `http://127.0.0.1:${existingState.port}`;
293
294
  throw new Error(
294
- `Session "${session}" is already open and connected to ${endpoint} (pid ${existingState.pid}). Create a new session or close the current one with: libretto close --session ${session}`,
295
+ `Session "${session}" is already open and connected to ${endpoint} (pid ${existingState.pid}). Create a new session or close the current one with: ${librettoCommand(`close --session ${session}`)}`,
295
296
  );
296
297
  }
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { REPO_ROOT } from "./context.js";
5
+ import { librettoCommand } from "../../shared/package-manager.js";
5
6
 
6
7
  type PackageManifest = {
7
8
  version?: string;
@@ -85,7 +86,7 @@ export function warnIfInstalledSkillOutOfDate(): void {
85
86
  }
86
87
 
87
88
  console.error(
88
- `Warning: Your agent skill (${mismatch.installedVersion}) is out of date with your Libretto CLI (${mismatch.cliVersion}). Please run \`npx libretto setup\` to update your skills to the correct version.`,
89
+ `Warning: Your agent skill (${mismatch.installedVersion}) is out of date with your Libretto CLI (${mismatch.cliVersion}). Please run \`${librettoCommand("setup")}\` to update your skills to the correct version.`,
89
90
  );
90
91
  } catch {
91
92
  // Never block command execution on a best-effort skill version check.
@@ -17,6 +17,7 @@ import { spawn } from "node:child_process";
17
17
  import { tmpdir } from "node:os";
18
18
  import { z } from "zod";
19
19
  import type { LoggerApi } from "../../shared/logger/index.js";
20
+ import { librettoCommand } from "../../shared/package-manager.js";
20
21
  // Used by the legacy CLI-agent path (UserCodingAgent) which is preserved but
21
22
  // not wired into the snapshot command. The active config schema (ai-config.ts)
22
23
  // no longer has preset/commandPrefix — this type is kept for the legacy code.
@@ -297,7 +298,7 @@ async function runExternalCommand(
297
298
  if (error.code === "ENOENT") {
298
299
  reject(
299
300
  new Error(
300
- `Command not found: ${command}. Configure AI with 'libretto ai configure'.`,
301
+ `Command not found: ${command}. Configure AI with '${librettoCommand("ai configure")}'.`,
301
302
  ),
302
303
  );
303
304
  return;
@@ -830,7 +831,7 @@ export async function runInterpret(
830
831
  const configuredAgent = UserCodingAgent.getConfigured();
831
832
  if (!configuredAgent) {
832
833
  throw new Error(
833
- "No AI config set. Run 'npx libretto ai configure codex' (or claude/gemini), or set API credentials in your .env file for direct API analysis.",
834
+ `No AI config set. Run '${librettoCommand("ai configure codex")}' (or claude/gemini), or set API credentials in your .env file for direct API analysis.`,
834
835
  );
835
836
  }
836
837
 
@@ -840,7 +841,7 @@ export async function runInterpret(
840
841
  // re-enabled, the caller must supply a valid provider/model-id string.
841
842
  throw new Error(
842
843
  "The CLI-agent snapshot analysis path is not active. " +
843
- "Update your config to the current format with `npx libretto ai configure <provider>`, " +
844
+ `Update your config to the current format with \`${librettoCommand("ai configure <provider>")}\`, ` +
844
845
  "or set API credentials in .env for direct API analysis.",
845
846
  );
846
847
 
@@ -0,0 +1,237 @@
1
+ import type { BrowserContext, Page } from "playwright";
2
+ import type { LoggerApi } from "../../../shared/logger/index.js";
3
+ import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
4
+ import type {
5
+ ExportedLibrettoWorkflow,
6
+ LibrettoWorkflowContext,
7
+ } from "../../../shared/workflow/workflow.js";
8
+ import {
9
+ getAbsoluteIntegrationPath,
10
+ installHeadedWorkflowVisualization,
11
+ loadDefaultWorkflow,
12
+ } from "../workflow-runtime.js";
13
+
14
+ type WorkflowPausedState = {
15
+ state: "paused";
16
+ session: string;
17
+ pausedAt: string;
18
+ url?: string;
19
+ };
20
+
21
+ export type WorkflowFinishedArgs =
22
+ | { result: "completed"; completedAt: string }
23
+ | { result: "failed"; message: string; phase: "setup" | "workflow" };
24
+
25
+ type WorkflowFinishedState = {
26
+ state: "finished";
27
+ } & WorkflowFinishedArgs;
28
+
29
+ export type WorkflowOutcome = WorkflowPausedState | WorkflowFinishedState;
30
+
31
+ export type WorkflowStatus =
32
+ | { state: "idle" }
33
+ | { state: "running" }
34
+ | WorkflowOutcome;
35
+
36
+ type WorkflowLogEvent = {
37
+ stream: "stdout" | "stderr";
38
+ text: string;
39
+ };
40
+
41
+ export type WorkflowControllerConfig = {
42
+ session: string;
43
+ headed: boolean;
44
+ page: Page;
45
+ context: BrowserContext;
46
+ logger: LoggerApi;
47
+ onLog?: (event: WorkflowLogEvent) => void;
48
+ onOutcome?: (outcome: WorkflowOutcome) => void;
49
+ };
50
+
51
+ export type WorkflowStartConfig = {
52
+ integrationPath: string;
53
+ params?: unknown;
54
+ visualize?: boolean;
55
+ loadedWorkflow?: ExportedLibrettoWorkflow;
56
+ };
57
+
58
+ type PendingPause = {
59
+ resolve(): void;
60
+ };
61
+
62
+ type WritableStreamWithWrite = NodeJS.WriteStream & {
63
+ write: NodeJS.WriteStream["write"];
64
+ };
65
+
66
+ export class WorkflowController {
67
+ private status: WorkflowStatus = { state: "idle" };
68
+ private pendingPause: PendingPause | undefined;
69
+ private started = false;
70
+
71
+ constructor(private readonly config: WorkflowControllerConfig) {}
72
+
73
+ start(workflowConfig: WorkflowStartConfig): void {
74
+ if (this.started) {
75
+ throw new Error("Workflow controller has already started.");
76
+ }
77
+
78
+ this.started = true;
79
+ this.status = { state: "running" };
80
+ void this.run(workflowConfig);
81
+ }
82
+
83
+ pause(args: {
84
+ session: string;
85
+ pausedAt: string;
86
+ url?: string;
87
+ }): Promise<void> {
88
+ if (this.pendingPause) {
89
+ throw new Error("Workflow is already paused.");
90
+ }
91
+
92
+ return new Promise<void>((resolve) => {
93
+ this.pendingPause = { resolve };
94
+ this.status = { state: "paused", ...args };
95
+ this.config.onOutcome?.(this.status);
96
+ });
97
+ }
98
+
99
+ resume(): void {
100
+ if (!this.pendingPause) {
101
+ throw new Error("Workflow is not paused.");
102
+ }
103
+
104
+ const pendingPause = this.pendingPause;
105
+ this.pendingPause = undefined;
106
+ this.status = { state: "running" };
107
+ pendingPause.resolve();
108
+ }
109
+
110
+ getStatus(): WorkflowStatus {
111
+ return this.status;
112
+ }
113
+
114
+ private async run(workflowConfig: WorkflowStartConfig): Promise<void> {
115
+ const restoreOutput = this.captureProcessOutput();
116
+ try {
117
+ const absolutePath = getAbsoluteIntegrationPath(
118
+ workflowConfig.integrationPath,
119
+ );
120
+ const workflow =
121
+ workflowConfig.loadedWorkflow ??
122
+ (await loadDefaultWorkflow(absolutePath));
123
+ const workflowLogger = this.config.logger.withScope("integration-run", {
124
+ integrationPath: absolutePath,
125
+ workflowName: workflow.name,
126
+ session: this.config.session,
127
+ });
128
+
129
+ console.log(
130
+ `Running workflow "${workflow.name}" from ${absolutePath} (${this.config.headed ? "headed" : "headless"})...`,
131
+ );
132
+
133
+ if (this.config.headed && workflowConfig.visualize !== false) {
134
+ await installHeadedWorkflowVisualization({
135
+ context: this.config.context,
136
+ logger: workflowLogger,
137
+ });
138
+ }
139
+
140
+ // tsx/esbuild can inject __name() wrappers when keepNames is true.
141
+ // Playwright serializes callbacks via Function#toString() into the browser
142
+ // context, which lacks __name, causing ReferenceError without this polyfill.
143
+ await this.config.context.addInitScript(() => {
144
+ (globalThis as Record<string, unknown>).__name = (
145
+ target: unknown,
146
+ value: string,
147
+ ) =>
148
+ Object.defineProperty(target as object, "name", {
149
+ value,
150
+ configurable: true,
151
+ });
152
+ });
153
+
154
+ const workflowContext: LibrettoWorkflowContext = {
155
+ session: this.config.session,
156
+ page: this.config.page,
157
+ };
158
+
159
+ const uninstallPauseHandler = installPauseHandler((pauseArgs) =>
160
+ this.pause({
161
+ ...pauseArgs,
162
+ url: this.config.page.isClosed() ? undefined : this.config.page.url(),
163
+ }),
164
+ );
165
+ try {
166
+ await workflow.run(workflowContext, workflowConfig.params ?? {});
167
+ } catch (error) {
168
+ this.emitOutcome({
169
+ state: "finished",
170
+ result: "failed",
171
+ message: error instanceof Error ? error.message : String(error),
172
+ phase: "workflow",
173
+ });
174
+ return;
175
+ } finally {
176
+ uninstallPauseHandler();
177
+ }
178
+
179
+ this.emitOutcome({
180
+ state: "finished",
181
+ result: "completed",
182
+ completedAt: new Date().toISOString(),
183
+ });
184
+ } catch (error) {
185
+ this.emitOutcome({
186
+ state: "finished",
187
+ result: "failed",
188
+ message: error instanceof Error ? error.message : String(error),
189
+ phase: "setup",
190
+ });
191
+ } finally {
192
+ restoreOutput();
193
+ }
194
+ }
195
+
196
+ private emitOutcome(outcome: WorkflowOutcome): void {
197
+ this.resolvePendingPause();
198
+ this.status = outcome;
199
+ this.config.onOutcome?.(outcome);
200
+ }
201
+
202
+ private resolvePendingPause(): void {
203
+ const pendingPause = this.pendingPause;
204
+ if (!pendingPause) return;
205
+
206
+ this.pendingPause = undefined;
207
+ pendingPause.resolve();
208
+ }
209
+
210
+ private captureProcessOutput(): () => void {
211
+ const stdout = process.stdout as WritableStreamWithWrite;
212
+ const stderr = process.stderr as WritableStreamWithWrite;
213
+ const originalStdoutWrite = stdout.write;
214
+ const originalStderrWrite = stderr.write;
215
+
216
+ stdout.write = ((...writeArgs: Parameters<typeof stdout.write>) => {
217
+ const [chunk] = writeArgs;
218
+ this.config.onLog?.({ stream: "stdout", text: chunkToString(chunk) });
219
+ return Reflect.apply(originalStdoutWrite, stdout, writeArgs) as boolean;
220
+ }) as typeof stdout.write;
221
+
222
+ stderr.write = ((...writeArgs: Parameters<typeof stderr.write>) => {
223
+ const [chunk] = writeArgs;
224
+ this.config.onLog?.({ stream: "stderr", text: chunkToString(chunk) });
225
+ return Reflect.apply(originalStderrWrite, stderr, writeArgs) as boolean;
226
+ }) as typeof stderr.write;
227
+
228
+ return () => {
229
+ stdout.write = originalStdoutWrite;
230
+ stderr.write = originalStderrWrite;
231
+ };
232
+ }
233
+ }
234
+
235
+ function chunkToString(chunk: unknown): string {
236
+ return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
237
+ }
@@ -0,0 +1,85 @@
1
+ import type { BrowserContext } from "playwright";
2
+ import { existsSync } from "node:fs";
3
+ import { cwd } from "node:process";
4
+ import { isAbsolute, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { instrumentContext } from "../../index.js";
7
+ import {
8
+ getDefaultWorkflowFromModuleExports,
9
+ getWorkflowsFromModuleExports,
10
+ type ExportedLibrettoWorkflow,
11
+ } from "../../shared/workflow/workflow.js";
12
+ import type { LoggerApi } from "../../shared/logger/index.js";
13
+
14
+ const TSCONFIG_HINT =
15
+ "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
16
+
17
+ function isTsxCompileError(error: unknown): error is Error {
18
+ return (
19
+ error instanceof Error &&
20
+ (error.name === "TransformError" ||
21
+ error.message.startsWith("Cannot resolve tsconfig at path:"))
22
+ );
23
+ }
24
+
25
+ export function getAbsoluteIntegrationPath(integrationPath: string): string {
26
+ const absolutePath = isAbsolute(integrationPath)
27
+ ? integrationPath
28
+ : resolve(cwd(), integrationPath);
29
+ if (!existsSync(absolutePath)) {
30
+ throw new Error(`Integration file does not exist: ${absolutePath}`);
31
+ }
32
+ return absolutePath;
33
+ }
34
+
35
+ export async function loadDefaultWorkflow(
36
+ absolutePath: string,
37
+ ): Promise<ExportedLibrettoWorkflow> {
38
+ let loadedModule: Record<string, unknown>;
39
+ try {
40
+ loadedModule = (await import(pathToFileURL(absolutePath).href)) as Record<
41
+ string,
42
+ unknown
43
+ >;
44
+ } catch (error) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ const compileHint = isTsxCompileError(error)
47
+ ? `\n${TSCONFIG_HINT}`
48
+ : absolutePath.endsWith(".ts") || absolutePath.endsWith(".tsx")
49
+ ? `\n${TSCONFIG_HINT}`
50
+ : "";
51
+ throw new Error(
52
+ `Failed to import integration module at ${absolutePath}: ${message}${compileHint}`,
53
+ );
54
+ }
55
+
56
+ const defaultWorkflow = getDefaultWorkflowFromModuleExports(loadedModule);
57
+ if (defaultWorkflow) {
58
+ return defaultWorkflow;
59
+ }
60
+
61
+ const availableWorkflowNames = getWorkflowsFromModuleExports(loadedModule).map(
62
+ (candidate) => candidate.name,
63
+ );
64
+
65
+ if (availableWorkflowNames.length === 0) {
66
+ throw new Error(
67
+ `No default-exported workflow found in ${absolutePath}. Export the workflow with \`export default workflow("name", handler)\`.`,
68
+ );
69
+ }
70
+
71
+ throw new Error(
72
+ `No default-exported workflow found in ${absolutePath}. libretto run only uses the file's default export. Available named workflows: ${availableWorkflowNames.join(", ")}`,
73
+ );
74
+ }
75
+
76
+ export async function installHeadedWorkflowVisualization(args: {
77
+ context: BrowserContext;
78
+ logger: LoggerApi;
79
+ instrument?: typeof instrumentContext;
80
+ }): Promise<void> {
81
+ await (args.instrument ?? instrumentContext)(args.context, {
82
+ visualize: true,
83
+ logger: args.logger,
84
+ });
85
+ }
package/src/cli/router.ts CHANGED
@@ -4,14 +4,17 @@ import { billingCommands } from "./commands/billing.js";
4
4
  import { browserCommands } from "./commands/browser.js";
5
5
  import { deployCommand } from "./commands/deploy.js";
6
6
  import { executionCommands } from "./commands/execution.js";
7
+ import { experimentsCommand } from "./commands/experiments.js";
7
8
  import { setupCommand } from "./commands/setup.js";
8
9
  import { statusCommand } from "./commands/status.js";
9
10
  import { snapshotCommand } from "./commands/snapshot.js";
11
+ import { librettoCommand } from "../shared/package-manager.js";
10
12
  import { SimpleCLI } from "./framework/simple-cli.js";
11
13
 
12
14
  export const cliRoutes = {
13
15
  ...browserCommands,
14
16
  deploy: deployCommand,
17
+ experiments: experimentsCommand,
15
18
  ...executionCommands,
16
19
  ai: aiCommands,
17
20
  auth: authCommands,
@@ -22,5 +25,5 @@ export const cliRoutes = {
22
25
  };
23
26
 
24
27
  export function createCLIApp() {
25
- return SimpleCLI.define("libretto", cliRoutes);
28
+ return SimpleCLI.define(librettoCommand(), cliRoutes);
26
29
  }