libretto 0.4.4 → 0.5.0
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/dist/cli/cli.js +20 -19
- package/dist/cli/commands/ai.js +1 -1
- package/dist/cli/commands/browser.js +3 -3
- package/dist/cli/commands/execution.js +3 -3
- package/dist/cli/commands/logs.js +1 -1
- package/dist/cli/core/browser.js +11 -6
- package/dist/cli/core/context.js +4 -18
- package/dist/cli/core/session.js +2 -2
- package/dist/cli/core/snapshot-analyzer.js +2 -2
- package/dist/cli/router.js +1 -1
- package/dist/cli/workers/run-integration-runtime.js +2 -2
- package/dist/shared/paths/paths.js +2 -1
- package/dist/shared/paths/repo-root.d.ts +3 -0
- package/dist/shared/paths/repo-root.js +24 -0
- package/package.json +6 -7
- package/scripts/postinstall.mjs +12 -3
- package/skills/libretto/SKILL.md +93 -404
- package/skills/libretto/references/auth-profiles.md +30 -0
- package/skills/libretto/references/pages-and-page-targeting.md +29 -0
- package/skills/libretto/references/reverse-engineering-network-requests.md +39 -0
- package/skills/libretto/references/user-action-log.md +31 -0
- package/src/cli/cli.ts +173 -0
- package/src/cli/commands/ai.ts +35 -0
- package/src/cli/commands/browser.ts +165 -0
- package/src/cli/commands/execution.ts +691 -0
- package/src/cli/commands/init.ts +327 -0
- package/src/cli/commands/logs.ts +128 -0
- package/src/cli/commands/shared.ts +70 -0
- package/src/cli/commands/snapshot.ts +327 -0
- package/src/cli/core/ai-config.ts +255 -0
- package/src/cli/core/api-snapshot-analyzer.ts +97 -0
- package/src/cli/core/browser.ts +839 -0
- package/src/cli/core/context.ts +122 -0
- package/src/cli/core/pause-signals.ts +35 -0
- package/src/cli/core/session-telemetry.ts +553 -0
- package/src/cli/core/session.ts +209 -0
- package/src/cli/core/snapshot-analyzer.ts +875 -0
- package/src/cli/core/snapshot-api-config.ts +236 -0
- package/src/cli/core/telemetry.ts +446 -0
- package/src/cli/framework/simple-cli.ts +1273 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/router.ts +28 -0
- package/src/cli/workers/run-integration-runtime.ts +311 -0
- package/src/cli/workers/run-integration-worker-protocol.ts +14 -0
- package/src/cli/workers/run-integration-worker.ts +75 -0
- package/src/index.ts +120 -0
- package/src/runtime/download/download.ts +100 -0
- package/src/runtime/download/index.ts +7 -0
- package/src/runtime/extract/extract.ts +92 -0
- package/src/runtime/extract/index.ts +1 -0
- package/src/runtime/network/index.ts +5 -0
- package/src/runtime/network/network.ts +113 -0
- package/src/runtime/recovery/agent.ts +256 -0
- package/src/runtime/recovery/errors.ts +152 -0
- package/src/runtime/recovery/index.ts +7 -0
- package/src/runtime/recovery/recovery.ts +50 -0
- package/{dist/shared/condense-dom/condense-dom.cjs → src/shared/condense-dom/condense-dom.ts} +243 -115
- package/src/shared/config/config.ts +22 -0
- package/src/shared/config/index.ts +5 -0
- package/src/shared/debug/index.ts +1 -0
- package/src/shared/debug/pause.ts +85 -0
- package/src/shared/instrumentation/errors.ts +82 -0
- package/src/shared/instrumentation/index.ts +9 -0
- package/src/shared/instrumentation/instrument.ts +276 -0
- package/src/shared/llm/ai-sdk-adapter.ts +78 -0
- package/src/shared/llm/client.ts +217 -0
- package/src/shared/llm/index.ts +3 -0
- package/src/shared/llm/types.ts +63 -0
- package/src/shared/logger/index.ts +6 -0
- package/src/shared/logger/logger.ts +352 -0
- package/src/shared/logger/sinks.ts +144 -0
- package/src/shared/paths/paths.ts +109 -0
- package/src/shared/paths/repo-root.ts +27 -0
- package/src/shared/run/api.ts +2 -0
- package/src/shared/run/browser.ts +98 -0
- package/src/shared/state/index.ts +11 -0
- package/src/shared/state/session-state.ts +74 -0
- package/src/shared/visualization/ghost-cursor.ts +200 -0
- package/src/shared/visualization/highlight.ts +146 -0
- package/src/shared/visualization/index.ts +18 -0
- package/src/shared/workflow/workflow.ts +42 -0
- package/dist/index.cjs +0 -144
- package/dist/index.d.cts +0 -21
- package/dist/runtime/download/download.cjs +0 -70
- package/dist/runtime/download/download.d.cts +0 -35
- package/dist/runtime/download/index.cjs +0 -30
- package/dist/runtime/download/index.d.cts +0 -3
- package/dist/runtime/extract/extract.cjs +0 -88
- package/dist/runtime/extract/extract.d.cts +0 -23
- package/dist/runtime/extract/index.cjs +0 -28
- package/dist/runtime/extract/index.d.cts +0 -5
- package/dist/runtime/network/index.cjs +0 -28
- package/dist/runtime/network/index.d.cts +0 -4
- package/dist/runtime/network/network.cjs +0 -91
- package/dist/runtime/network/network.d.cts +0 -28
- package/dist/runtime/recovery/agent.cjs +0 -223
- package/dist/runtime/recovery/agent.d.cts +0 -13
- package/dist/runtime/recovery/errors.cjs +0 -124
- package/dist/runtime/recovery/errors.d.cts +0 -31
- package/dist/runtime/recovery/index.cjs +0 -34
- package/dist/runtime/recovery/index.d.cts +0 -7
- package/dist/runtime/recovery/recovery.cjs +0 -55
- package/dist/runtime/recovery/recovery.d.cts +0 -12
- package/dist/shared/condense-dom/condense-dom.d.cts +0 -34
- package/dist/shared/config/config.cjs +0 -44
- package/dist/shared/config/config.d.cts +0 -10
- package/dist/shared/config/index.cjs +0 -32
- package/dist/shared/config/index.d.cts +0 -1
- package/dist/shared/debug/index.cjs +0 -28
- package/dist/shared/debug/index.d.cts +0 -1
- package/dist/shared/debug/pause.cjs +0 -86
- package/dist/shared/debug/pause.d.cts +0 -12
- package/dist/shared/instrumentation/errors.cjs +0 -81
- package/dist/shared/instrumentation/errors.d.cts +0 -12
- package/dist/shared/instrumentation/index.cjs +0 -35
- package/dist/shared/instrumentation/index.d.cts +0 -6
- package/dist/shared/instrumentation/instrument.cjs +0 -206
- package/dist/shared/instrumentation/instrument.d.cts +0 -32
- package/dist/shared/llm/ai-sdk-adapter.cjs +0 -71
- package/dist/shared/llm/ai-sdk-adapter.d.cts +0 -22
- package/dist/shared/llm/client.cjs +0 -218
- package/dist/shared/llm/client.d.cts +0 -13
- package/dist/shared/llm/index.cjs +0 -31
- package/dist/shared/llm/index.d.cts +0 -5
- package/dist/shared/llm/types.cjs +0 -16
- package/dist/shared/llm/types.d.cts +0 -67
- package/dist/shared/logger/index.cjs +0 -37
- package/dist/shared/logger/index.d.cts +0 -2
- package/dist/shared/logger/logger.cjs +0 -232
- package/dist/shared/logger/logger.d.cts +0 -86
- package/dist/shared/logger/sinks.cjs +0 -160
- package/dist/shared/logger/sinks.d.cts +0 -9
- package/dist/shared/paths/paths.cjs +0 -104
- package/dist/shared/paths/paths.d.cts +0 -10
- package/dist/shared/run/api.cjs +0 -28
- package/dist/shared/run/api.d.cts +0 -2
- package/dist/shared/run/browser.cjs +0 -98
- package/dist/shared/run/browser.d.cts +0 -22
- package/dist/shared/state/index.cjs +0 -38
- package/dist/shared/state/index.d.cts +0 -2
- package/dist/shared/state/session-state.cjs +0 -92
- package/dist/shared/state/session-state.d.cts +0 -40
- package/dist/shared/visualization/ghost-cursor.cjs +0 -174
- package/dist/shared/visualization/ghost-cursor.d.cts +0 -37
- package/dist/shared/visualization/highlight.cjs +0 -134
- package/dist/shared/visualization/highlight.d.cts +0 -22
- package/dist/shared/visualization/index.cjs +0 -45
- package/dist/shared/visualization/index.d.cts +0 -3
- package/dist/shared/workflow/workflow.cjs +0 -47
- package/dist/shared/workflow/workflow.d.cts +0 -21
- package/skills/libretto/code-generation-rules.md +0 -223
- package/skills/libretto/integration-approach-selection.md +0 -174
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as moduleBuiltin from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { installInstrumentation } from "../../shared/instrumentation/index.js";
|
|
7
|
+
import type { LoggerApi } from "../../shared/logger/index.js";
|
|
8
|
+
import {
|
|
9
|
+
connect,
|
|
10
|
+
disconnectBrowser,
|
|
11
|
+
} from "../core/browser.js";
|
|
12
|
+
import { getPauseSignalPaths } from "../core/pause-signals.js";
|
|
13
|
+
import {
|
|
14
|
+
assertSessionAvailableForStart,
|
|
15
|
+
clearSessionState,
|
|
16
|
+
readSessionState,
|
|
17
|
+
setSessionStatus,
|
|
18
|
+
type SessionState,
|
|
19
|
+
} from "../core/session.js";
|
|
20
|
+
import {
|
|
21
|
+
readActionLog,
|
|
22
|
+
readNetworkLog,
|
|
23
|
+
wrapPageForActionLogging,
|
|
24
|
+
} from "../core/telemetry.js";
|
|
25
|
+
import type {
|
|
26
|
+
RunIntegrationWorkerRequest,
|
|
27
|
+
} from "../workers/run-integration-worker-protocol.js";
|
|
28
|
+
import { SimpleCLI } from "../framework/simple-cli.js";
|
|
29
|
+
import {
|
|
30
|
+
loadSessionStateMiddleware,
|
|
31
|
+
pageOption,
|
|
32
|
+
resolveSessionMiddleware,
|
|
33
|
+
sessionOption,
|
|
34
|
+
} from "./shared.js";
|
|
35
|
+
|
|
36
|
+
type ExecFunction = (...args: unknown[]) => Promise<unknown>;
|
|
37
|
+
type RunIntegrationCommandRequest = RunIntegrationWorkerRequest & {
|
|
38
|
+
tsconfigPath?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type StripTypeScriptTypesFn = (
|
|
42
|
+
code: string,
|
|
43
|
+
options?: { mode?: "strip" | "transform" },
|
|
44
|
+
) => string;
|
|
45
|
+
|
|
46
|
+
const stripTypeScriptTypes = (
|
|
47
|
+
moduleBuiltin as { stripTypeScriptTypes?: StripTypeScriptTypesFn }
|
|
48
|
+
).stripTypeScriptTypes;
|
|
49
|
+
const require = moduleBuiltin.createRequire(import.meta.url);
|
|
50
|
+
const tsxCliPath = require.resolve("tsx/cli");
|
|
51
|
+
|
|
52
|
+
function withSuppressedStripTypeScriptWarning<T>(action: () => T): T {
|
|
53
|
+
type EmitWarningFn = (...args: unknown[]) => void;
|
|
54
|
+
const mutableProcess = process as unknown as { emitWarning: EmitWarningFn };
|
|
55
|
+
const originalEmitWarning = mutableProcess.emitWarning;
|
|
56
|
+
|
|
57
|
+
mutableProcess.emitWarning = (...args: unknown[]) => {
|
|
58
|
+
const warning = args[0];
|
|
59
|
+
const typeOrOptions = args[1];
|
|
60
|
+
const warningMessage =
|
|
61
|
+
typeof warning === "string"
|
|
62
|
+
? warning
|
|
63
|
+
: warning instanceof Error
|
|
64
|
+
? warning.message
|
|
65
|
+
: "";
|
|
66
|
+
const warningType =
|
|
67
|
+
typeof typeOrOptions === "string"
|
|
68
|
+
? typeOrOptions
|
|
69
|
+
: typeof typeOrOptions === "object" &&
|
|
70
|
+
typeOrOptions !== null &&
|
|
71
|
+
"type" in typeOrOptions &&
|
|
72
|
+
typeof (typeOrOptions as { type?: unknown }).type === "string"
|
|
73
|
+
? ((typeOrOptions as { type?: string }).type ?? "")
|
|
74
|
+
: "";
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
warningType === "ExperimentalWarning" &&
|
|
78
|
+
warningMessage.includes("stripTypeScriptTypes")
|
|
79
|
+
) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
originalEmitWarning(...args);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return action();
|
|
87
|
+
} finally {
|
|
88
|
+
mutableProcess.emitWarning = originalEmitWarning;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compileTypeScriptExecFunction(
|
|
93
|
+
code: string,
|
|
94
|
+
helperNames: string[],
|
|
95
|
+
): ExecFunction | null {
|
|
96
|
+
if (!stripTypeScriptTypes) return null;
|
|
97
|
+
|
|
98
|
+
const wrappedSource = `(async function __librettoExec(${helperNames.join(", ")}) {\n${code}\n})`;
|
|
99
|
+
const jsSource = withSuppressedStripTypeScriptWarning(() =>
|
|
100
|
+
stripTypeScriptTypes(wrappedSource, { mode: "strip" }),
|
|
101
|
+
);
|
|
102
|
+
const createFunction = new Function(
|
|
103
|
+
`return ${jsSource}`,
|
|
104
|
+
) as () => ExecFunction;
|
|
105
|
+
return createFunction();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function compileExecFunction(
|
|
109
|
+
code: string,
|
|
110
|
+
helperNames: string[],
|
|
111
|
+
): ExecFunction {
|
|
112
|
+
const typeStripped = compileTypeScriptExecFunction(code, helperNames);
|
|
113
|
+
if (typeStripped) return typeStripped;
|
|
114
|
+
|
|
115
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {})
|
|
116
|
+
.constructor as new (...args: string[]) => ExecFunction;
|
|
117
|
+
return new AsyncFunction(...helperNames, code);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runExec(
|
|
121
|
+
code: string,
|
|
122
|
+
session: string,
|
|
123
|
+
logger: LoggerApi,
|
|
124
|
+
visualize = false,
|
|
125
|
+
pageId?: string,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
logger.info("exec-start", {
|
|
128
|
+
session,
|
|
129
|
+
codeLength: code.length,
|
|
130
|
+
codePreview: code.slice(0, 200),
|
|
131
|
+
visualize,
|
|
132
|
+
pageId,
|
|
133
|
+
});
|
|
134
|
+
const { browser, context, page, pageId: resolvedPageId } = await connect(
|
|
135
|
+
session,
|
|
136
|
+
logger,
|
|
137
|
+
10000,
|
|
138
|
+
{
|
|
139
|
+
pageId,
|
|
140
|
+
requireSinglePage: true,
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const STALL_THRESHOLD_MS = 60_000;
|
|
145
|
+
let lastActivityTs = Date.now();
|
|
146
|
+
const onActivity = () => {
|
|
147
|
+
lastActivityTs = Date.now();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const stallInterval = setInterval(() => {
|
|
151
|
+
const silenceMs = Date.now() - lastActivityTs;
|
|
152
|
+
if (silenceMs >= STALL_THRESHOLD_MS) {
|
|
153
|
+
logger.warn("exec-stall-warning", {
|
|
154
|
+
session,
|
|
155
|
+
silenceMs,
|
|
156
|
+
codePreview: code.slice(0, 200),
|
|
157
|
+
});
|
|
158
|
+
console.warn(
|
|
159
|
+
`[stall-warning] No Playwright activity for ${Math.round(silenceMs / 1000)}s — exec may be hung (code: ${code.slice(0, 100)}...)`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}, STALL_THRESHOLD_MS);
|
|
163
|
+
|
|
164
|
+
const execStartTs = Date.now();
|
|
165
|
+
const sigintHandler = () => {
|
|
166
|
+
logger.info("exec-interrupted", {
|
|
167
|
+
session,
|
|
168
|
+
duration: Date.now() - execStartTs,
|
|
169
|
+
codePreview: code.slice(0, 200),
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
process.on("SIGINT", sigintHandler);
|
|
173
|
+
|
|
174
|
+
wrapPageForActionLogging(page, session, resolvedPageId, onActivity);
|
|
175
|
+
|
|
176
|
+
if (visualize) {
|
|
177
|
+
await installInstrumentation(page, { visualize: true, logger });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const execState: Record<string, unknown> = {};
|
|
182
|
+
|
|
183
|
+
const networkLog = (
|
|
184
|
+
opts: { last?: number; filter?: string; method?: string; pageId?: string } = {},
|
|
185
|
+
) => {
|
|
186
|
+
return readNetworkLog(session, opts);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const actionLog = (
|
|
190
|
+
opts: {
|
|
191
|
+
last?: number;
|
|
192
|
+
filter?: string;
|
|
193
|
+
action?: string;
|
|
194
|
+
source?: string;
|
|
195
|
+
pageId?: string;
|
|
196
|
+
} = {},
|
|
197
|
+
) => {
|
|
198
|
+
return readActionLog(session, opts);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const helpers = {
|
|
202
|
+
page,
|
|
203
|
+
context,
|
|
204
|
+
state: execState,
|
|
205
|
+
browser,
|
|
206
|
+
networkLog,
|
|
207
|
+
actionLog,
|
|
208
|
+
console,
|
|
209
|
+
setTimeout,
|
|
210
|
+
setInterval,
|
|
211
|
+
clearTimeout,
|
|
212
|
+
clearInterval,
|
|
213
|
+
fetch,
|
|
214
|
+
URL,
|
|
215
|
+
Buffer,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const helperNames = Object.keys(helpers);
|
|
219
|
+
const fn = compileExecFunction(code, helperNames);
|
|
220
|
+
|
|
221
|
+
const result = await fn(...Object.values(helpers));
|
|
222
|
+
logger.info("exec-success", { session, hasResult: result !== undefined });
|
|
223
|
+
if (result !== undefined) {
|
|
224
|
+
console.log(
|
|
225
|
+
typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logger.error("exec-error", {
|
|
230
|
+
error: err,
|
|
231
|
+
session,
|
|
232
|
+
codePreview: code.slice(0, 200),
|
|
233
|
+
});
|
|
234
|
+
throw err;
|
|
235
|
+
} finally {
|
|
236
|
+
clearInterval(stallInterval);
|
|
237
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
238
|
+
disconnectBrowser(browser, logger, session);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseJsonArg(label: string, raw: string): unknown {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(raw);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Invalid JSON in ${label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isProcessRunning(pid: number): boolean {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function stopExistingFailedRunSession(
|
|
262
|
+
session: string,
|
|
263
|
+
logger: LoggerApi,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const existingState = readSessionState(session, logger);
|
|
266
|
+
if (!existingState || existingState.status !== "failed") {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
logger.info("run-release-existing-failed-session", {
|
|
270
|
+
session,
|
|
271
|
+
pid: existingState.pid,
|
|
272
|
+
port: existingState.port,
|
|
273
|
+
});
|
|
274
|
+
clearSessionState(session, logger);
|
|
275
|
+
|
|
276
|
+
const stopDeadline = Date.now() + 3_000;
|
|
277
|
+
while (isProcessRunning(existingState.pid) && Date.now() < stopDeadline) {
|
|
278
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
|
279
|
+
}
|
|
280
|
+
if (isProcessRunning(existingState.pid)) {
|
|
281
|
+
logger.warn("run-release-existing-failed-session-timeout", {
|
|
282
|
+
session,
|
|
283
|
+
pid: existingState.pid,
|
|
284
|
+
});
|
|
285
|
+
console.warn(
|
|
286
|
+
`Existing failed workflow process for session "${session}" (pid ${existingState.pid}) is still shutting down; continuing.`,
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
console.log(
|
|
291
|
+
`Closed existing failed workflow process for session "${session}" (pid ${existingState.pid}).`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function readJsonFileIfExists(path: string): unknown {
|
|
296
|
+
if (!existsSync(path)) return null;
|
|
297
|
+
try {
|
|
298
|
+
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readFailureDetails(path: string): {
|
|
305
|
+
message?: string;
|
|
306
|
+
phase?: "setup" | "workflow";
|
|
307
|
+
} | null {
|
|
308
|
+
const raw = readJsonFileIfExists(path);
|
|
309
|
+
if (!raw || typeof raw !== "object") return null;
|
|
310
|
+
|
|
311
|
+
const message = (raw as { message?: unknown }).message;
|
|
312
|
+
const phase = (raw as { phase?: unknown }).phase;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
message: typeof message === "string" ? message : undefined,
|
|
316
|
+
phase: phase === "setup" || phase === "workflow" ? phase : undefined,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function waitForFailureDetails(
|
|
321
|
+
path: string,
|
|
322
|
+
timeoutMs = 1_000,
|
|
323
|
+
): Promise<{
|
|
324
|
+
message?: string;
|
|
325
|
+
phase?: "setup" | "workflow";
|
|
326
|
+
} | null> {
|
|
327
|
+
const deadline = Date.now() + timeoutMs;
|
|
328
|
+
while (Date.now() < deadline) {
|
|
329
|
+
const details = readFailureDetails(path);
|
|
330
|
+
if (details?.message) return details;
|
|
331
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
332
|
+
}
|
|
333
|
+
return readFailureDetails(path);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function streamOutputSince(path: string, offset: number): number {
|
|
337
|
+
if (!existsSync(path)) return offset;
|
|
338
|
+
const output = readFileSync(path);
|
|
339
|
+
if (output.length <= offset) return output.length;
|
|
340
|
+
process.stdout.write(output.subarray(offset));
|
|
341
|
+
return output.length;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
type WaitForWorkflowOutcomeArgs = {
|
|
345
|
+
session: string;
|
|
346
|
+
pid: number;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
type WorkflowOutcome = {
|
|
350
|
+
status: "completed" | "paused" | "failed" | "exited";
|
|
351
|
+
message?: string;
|
|
352
|
+
phase?: "setup" | "workflow";
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
function clearSignalIfExists(path: string): void {
|
|
356
|
+
if (!existsSync(path)) return;
|
|
357
|
+
try {
|
|
358
|
+
unlinkSync(path);
|
|
359
|
+
} catch {
|
|
360
|
+
// Ignore cleanup failures; next checks still validate actual state.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function waitForWorkflowOutcome(
|
|
365
|
+
args: WaitForWorkflowOutcomeArgs,
|
|
366
|
+
): Promise<WorkflowOutcome> {
|
|
367
|
+
const signalPaths = getPauseSignalPaths(args.session);
|
|
368
|
+
if (args.pid <= 0) {
|
|
369
|
+
return { status: "exited" };
|
|
370
|
+
}
|
|
371
|
+
let outputOffset = 0;
|
|
372
|
+
|
|
373
|
+
while (true) {
|
|
374
|
+
outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
|
|
375
|
+
|
|
376
|
+
if (existsSync(signalPaths.failedSignalPath)) {
|
|
377
|
+
outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
|
|
378
|
+
const failureDetails = await waitForFailureDetails(signalPaths.failedSignalPath);
|
|
379
|
+
return {
|
|
380
|
+
status: "failed",
|
|
381
|
+
message: failureDetails?.message,
|
|
382
|
+
phase: failureDetails?.phase,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (existsSync(signalPaths.completedSignalPath)) {
|
|
387
|
+
outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
|
|
388
|
+
return { status: "completed" };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (existsSync(signalPaths.pausedSignalPath)) {
|
|
392
|
+
outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
|
|
393
|
+
return { status: "paused" };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!isProcessRunning(args.pid)) {
|
|
397
|
+
outputOffset = streamOutputSince(signalPaths.outputSignalPath, outputOffset);
|
|
398
|
+
return { status: "exited" };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function runResume(
|
|
406
|
+
session: string,
|
|
407
|
+
logger: LoggerApi,
|
|
408
|
+
sessionState: SessionState,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
const {
|
|
411
|
+
pausedSignalPath,
|
|
412
|
+
resumeSignalPath,
|
|
413
|
+
completedSignalPath,
|
|
414
|
+
failedSignalPath,
|
|
415
|
+
outputSignalPath,
|
|
416
|
+
} = getPauseSignalPaths(session);
|
|
417
|
+
|
|
418
|
+
if (!existsSync(pausedSignalPath)) {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Session "${session}" is not paused. Run "libretto run ... --session ${session}" and call pause("${session}") first.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!isProcessRunning(sessionState.pid)) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`No active paused workflow found for session "${session}" (worker pid ${sessionState.pid} is not running).`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Clear stale pause/output markers before signaling resume so we always wait
|
|
431
|
+
// for the next pause/completion and only stream post-resume logs.
|
|
432
|
+
clearSignalIfExists(pausedSignalPath);
|
|
433
|
+
clearSignalIfExists(outputSignalPath);
|
|
434
|
+
clearSignalIfExists(completedSignalPath);
|
|
435
|
+
clearSignalIfExists(failedSignalPath);
|
|
436
|
+
setSessionStatus(session, "active", logger);
|
|
437
|
+
|
|
438
|
+
writeFileSync(
|
|
439
|
+
resumeSignalPath,
|
|
440
|
+
JSON.stringify(
|
|
441
|
+
{
|
|
442
|
+
resumedAt: new Date().toISOString(),
|
|
443
|
+
sourcePid: process.pid,
|
|
444
|
+
},
|
|
445
|
+
null,
|
|
446
|
+
2,
|
|
447
|
+
),
|
|
448
|
+
"utf8",
|
|
449
|
+
);
|
|
450
|
+
console.log(`Resume signal sent for session "${session}".`);
|
|
451
|
+
|
|
452
|
+
const outcome = await waitForWorkflowOutcome({
|
|
453
|
+
session,
|
|
454
|
+
pid: sessionState.pid,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
if (outcome.status === "completed") {
|
|
458
|
+
setSessionStatus(session, "completed", logger);
|
|
459
|
+
console.log("Integration completed.");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (outcome.status === "failed") {
|
|
463
|
+
setSessionStatus(session, "failed", logger);
|
|
464
|
+
throw new Error(
|
|
465
|
+
outcome.message
|
|
466
|
+
? `Workflow failed after resume: ${outcome.message}`
|
|
467
|
+
: "Workflow failed after resume.",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (outcome.status === "exited") {
|
|
471
|
+
setSessionStatus(session, "exited", logger);
|
|
472
|
+
throw new Error(
|
|
473
|
+
`Workflow process for session "${session}" exited before reporting completion or pause.`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
setSessionStatus(session, "paused", logger);
|
|
477
|
+
console.log("Workflow paused.");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function runIntegrationFromFile(
|
|
481
|
+
args: RunIntegrationCommandRequest,
|
|
482
|
+
logger: LoggerApi,
|
|
483
|
+
): Promise<void> {
|
|
484
|
+
await stopExistingFailedRunSession(args.session, logger);
|
|
485
|
+
const signalPaths = getPauseSignalPaths(args.session);
|
|
486
|
+
clearSignalIfExists(signalPaths.pausedSignalPath);
|
|
487
|
+
clearSignalIfExists(signalPaths.resumeSignalPath);
|
|
488
|
+
clearSignalIfExists(signalPaths.completedSignalPath);
|
|
489
|
+
clearSignalIfExists(signalPaths.failedSignalPath);
|
|
490
|
+
clearSignalIfExists(signalPaths.outputSignalPath);
|
|
491
|
+
|
|
492
|
+
const workerEntryPath = fileURLToPath(
|
|
493
|
+
new URL("../workers/run-integration-worker.js", import.meta.url),
|
|
494
|
+
);
|
|
495
|
+
const payload = JSON.stringify({
|
|
496
|
+
integrationPath: args.integrationPath,
|
|
497
|
+
exportName: args.exportName,
|
|
498
|
+
session: args.session,
|
|
499
|
+
params: args.params,
|
|
500
|
+
headless: args.headless,
|
|
501
|
+
authProfileDomain: args.authProfileDomain,
|
|
502
|
+
} satisfies RunIntegrationWorkerRequest);
|
|
503
|
+
const worker = spawn(process.execPath, [
|
|
504
|
+
tsxCliPath,
|
|
505
|
+
...(args.tsconfigPath ? ["--tsconfig", args.tsconfigPath] : []),
|
|
506
|
+
workerEntryPath,
|
|
507
|
+
payload,
|
|
508
|
+
], {
|
|
509
|
+
detached: true,
|
|
510
|
+
stdio: "ignore",
|
|
511
|
+
env: process.env,
|
|
512
|
+
});
|
|
513
|
+
worker.unref();
|
|
514
|
+
const outcome = await waitForWorkflowOutcome({
|
|
515
|
+
session: args.session,
|
|
516
|
+
pid: worker.pid ?? 0,
|
|
517
|
+
});
|
|
518
|
+
if (outcome.status === "paused") {
|
|
519
|
+
setSessionStatus(args.session, "paused", logger);
|
|
520
|
+
console.log("Workflow paused.");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (outcome.status === "failed") {
|
|
524
|
+
setSessionStatus(args.session, "failed", logger);
|
|
525
|
+
if (outcome.phase === "workflow") {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`${outcome.message ?? "Workflow failed during run."}\nBrowser is still open. You can use \`exec\` to inspect it. Call \`run\` to re-run the workflow.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
throw new Error(outcome.message ?? "Workflow failed during run.");
|
|
531
|
+
}
|
|
532
|
+
if (outcome.status === "exited") {
|
|
533
|
+
setSessionStatus(args.session, "exited", logger);
|
|
534
|
+
throw new Error(
|
|
535
|
+
"Workflow process exited before reporting completion or pause during run.",
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
setSessionStatus(args.session, "completed", logger);
|
|
539
|
+
console.log("Integration completed.");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export const execInput = SimpleCLI.input({
|
|
543
|
+
positionals: [
|
|
544
|
+
SimpleCLI.positional("codeParts", z.array(z.string()).default([]), {
|
|
545
|
+
help: "Playwright TypeScript code to execute",
|
|
546
|
+
variadic: true,
|
|
547
|
+
}),
|
|
548
|
+
],
|
|
549
|
+
named: {
|
|
550
|
+
session: sessionOption(),
|
|
551
|
+
visualize: SimpleCLI.flag({ help: "Enable ghost cursor + highlight visualization" }),
|
|
552
|
+
page: pageOption(),
|
|
553
|
+
},
|
|
554
|
+
}).refine(
|
|
555
|
+
(input) => input.codeParts.length > 0,
|
|
556
|
+
`Usage: libretto exec <code> [--session <name>] [--visualize]`,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
export function createExecCommand(logger: LoggerApi) {
|
|
560
|
+
return SimpleCLI.command({
|
|
561
|
+
description: "Execute Playwright TypeScript code",
|
|
562
|
+
})
|
|
563
|
+
.input(execInput)
|
|
564
|
+
.use(resolveSessionMiddleware)
|
|
565
|
+
.use(loadSessionStateMiddleware)
|
|
566
|
+
.handle(async ({ input, ctx }) => {
|
|
567
|
+
await runExec(
|
|
568
|
+
input.codeParts.join(" "),
|
|
569
|
+
ctx.session,
|
|
570
|
+
logger,
|
|
571
|
+
input.visualize,
|
|
572
|
+
input.page,
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const runUsage =
|
|
578
|
+
`Usage: libretto run <integrationFile> <integrationExport> [--params <json> | --params-file <path>] [--tsconfig <path>] [--headed|--headless]`;
|
|
579
|
+
|
|
580
|
+
export const runInput = SimpleCLI.input({
|
|
581
|
+
positionals: [
|
|
582
|
+
SimpleCLI.positional("integrationFile", z.string().optional(), {
|
|
583
|
+
help: "Path to the integration file",
|
|
584
|
+
}),
|
|
585
|
+
SimpleCLI.positional("integrationExport", z.string().optional(), {
|
|
586
|
+
help: "Named workflow export to run",
|
|
587
|
+
}),
|
|
588
|
+
],
|
|
589
|
+
named: {
|
|
590
|
+
session: sessionOption(),
|
|
591
|
+
params: SimpleCLI.option(z.string().optional(), {
|
|
592
|
+
help: "Inline JSON params",
|
|
593
|
+
}),
|
|
594
|
+
paramsFile: SimpleCLI.option(z.string().optional(), {
|
|
595
|
+
name: "params-file",
|
|
596
|
+
help: "Path to a JSON params file",
|
|
597
|
+
}),
|
|
598
|
+
tsconfig: SimpleCLI.option(z.string().optional(), {
|
|
599
|
+
help: "Path to a tsconfig used for workflow module resolution",
|
|
600
|
+
}),
|
|
601
|
+
headed: SimpleCLI.flag({ help: "Run in headed mode" }),
|
|
602
|
+
headless: SimpleCLI.flag({ help: "Run in headless mode" }),
|
|
603
|
+
authProfile: SimpleCLI.option(z.string().optional(), {
|
|
604
|
+
name: "auth-profile",
|
|
605
|
+
help: "Domain for local auth profile (e.g. apps.example.com)",
|
|
606
|
+
}),
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
.refine(
|
|
610
|
+
(input) => Boolean(input.integrationFile && input.integrationExport),
|
|
611
|
+
runUsage,
|
|
612
|
+
)
|
|
613
|
+
.refine((input) => !(input.params && input.paramsFile), "Pass either --params or --params-file, not both.")
|
|
614
|
+
.refine((input) => !(input.headed && input.headless), "Cannot pass both --headed and --headless.");
|
|
615
|
+
|
|
616
|
+
function resolveRunParams(
|
|
617
|
+
rawInlineParams: string | undefined,
|
|
618
|
+
paramsFile: string | undefined,
|
|
619
|
+
): unknown {
|
|
620
|
+
if (paramsFile) {
|
|
621
|
+
let content: string;
|
|
622
|
+
try {
|
|
623
|
+
content = readFileSync(paramsFile, "utf8");
|
|
624
|
+
} catch {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Could not read --params-file "${paramsFile}". Ensure the file exists and is readable.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return parseJsonArg("--params-file", content);
|
|
630
|
+
}
|
|
631
|
+
if (rawInlineParams) {
|
|
632
|
+
return parseJsonArg("--params", rawInlineParams);
|
|
633
|
+
}
|
|
634
|
+
return {};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function createRunCommand(logger: LoggerApi) {
|
|
638
|
+
return SimpleCLI.command({
|
|
639
|
+
description: "Run an exported Libretto workflow from a file",
|
|
640
|
+
})
|
|
641
|
+
.input(runInput)
|
|
642
|
+
.use(resolveSessionMiddleware)
|
|
643
|
+
.handle(async ({ input, ctx }) => {
|
|
644
|
+
await stopExistingFailedRunSession(ctx.session, logger);
|
|
645
|
+
assertSessionAvailableForStart(ctx.session, logger);
|
|
646
|
+
|
|
647
|
+
const params = resolveRunParams(input.params, input.paramsFile);
|
|
648
|
+
const headlessMode = input.headed
|
|
649
|
+
? false
|
|
650
|
+
: input.headless
|
|
651
|
+
? true
|
|
652
|
+
: undefined;
|
|
653
|
+
|
|
654
|
+
await runIntegrationFromFile({
|
|
655
|
+
integrationPath: input.integrationFile!,
|
|
656
|
+
exportName: input.integrationExport!,
|
|
657
|
+
session: ctx.session,
|
|
658
|
+
params,
|
|
659
|
+
tsconfigPath: input.tsconfig,
|
|
660
|
+
headless: headlessMode ?? false,
|
|
661
|
+
authProfileDomain: input.authProfile,
|
|
662
|
+
}, logger);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export const resumeInput = SimpleCLI.input({
|
|
667
|
+
positionals: [],
|
|
668
|
+
named: {
|
|
669
|
+
session: sessionOption(),
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
export function createResumeCommand(logger: LoggerApi) {
|
|
674
|
+
return SimpleCLI.command({
|
|
675
|
+
description: "Resume a paused workflow for the current session",
|
|
676
|
+
})
|
|
677
|
+
.input(resumeInput)
|
|
678
|
+
.use(resolveSessionMiddleware)
|
|
679
|
+
.use(loadSessionStateMiddleware)
|
|
680
|
+
.handle(async ({ ctx }) => {
|
|
681
|
+
await runResume(ctx.session, logger, ctx.sessionState);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function createExecutionCommands(logger: LoggerApi) {
|
|
686
|
+
return {
|
|
687
|
+
exec: createExecCommand(logger),
|
|
688
|
+
run: createRunCommand(logger),
|
|
689
|
+
resume: createResumeCommand(logger),
|
|
690
|
+
};
|
|
691
|
+
}
|