libretto 0.2.4 → 0.2.5
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 +4 -2
- package/dist/cli/commands/browser.js +26 -3
- package/dist/cli/commands/execution.js +50 -11
- package/dist/cli/core/browser.js +131 -6
- package/dist/cli/core/session.js +13 -13
- package/dist/cli/workers/run-integration-runtime.js +64 -59
- package/dist/cli/workers/run-integration-worker-protocol.js +12 -0
- package/dist/cli/workers/run-integration-worker.js +13 -30
- package/dist/index.cjs +2 -12
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -15
- package/dist/shared/debug/index.cjs +4 -6
- package/dist/shared/debug/index.d.cts +1 -2
- package/dist/shared/debug/index.d.ts +1 -2
- package/dist/shared/debug/index.js +3 -8
- package/dist/shared/debug/pause.cjs +58 -24
- package/dist/shared/debug/pause.d.cts +13 -20
- package/dist/shared/debug/pause.d.ts +13 -20
- package/dist/shared/debug/pause.js +46 -21
- package/dist/shared/llm/ai-sdk-adapter.cjs +7 -1
- package/dist/shared/llm/ai-sdk-adapter.js +7 -1
- package/dist/shared/run/api.cjs +0 -7
- package/dist/shared/run/api.d.cts +0 -1
- package/dist/shared/run/api.d.ts +0 -1
- package/dist/shared/run/api.js +0 -8
- package/dist/shared/workflow/workflow.d.cts +11 -24
- package/dist/shared/workflow/workflow.d.ts +11 -24
- package/package.json +1 -1
- package/skill/SKILL.md +18 -5
- package/skill/code-generation-rules.md +7 -10
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]
|
|
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
|
|
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(
|
|
48
|
-
|
|
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 {
|
|
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
|
|
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 =
|
|
343
|
+
const worker = spawn(process.execPath, [workerEntryPath, payload], {
|
|
312
344
|
detached: true,
|
|
313
|
-
stdio:
|
|
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(
|
|
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("
|
|
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]
|
|
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
|
|
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
|
-
|
|
447
|
+
authProfileDomain
|
|
409
448
|
}, logger);
|
|
410
449
|
}
|
|
411
450
|
).command(
|
package/dist/cli/core/browser.js
CHANGED
|
@@ -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
|
-
|
|
492
|
-
|
|
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
|
package/dist/cli/core/session.js
CHANGED
|
@@ -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
|
|
67
|
+
function listSessionsWithStateFile() {
|
|
69
68
|
if (!existsSync(LIBRETTO_SESSIONS_DIR)) return [];
|
|
70
|
-
return readdirSync(LIBRETTO_SESSIONS_DIR).filter(
|
|
71
|
-
|
|
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,
|
|
@@ -1,21 +1,23 @@
|
|
|
1
|
-
import { appendFileSync, existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
3
|
import { cwd } from "node:process";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import {
|
|
7
7
|
launchBrowser
|
|
8
8
|
} from "../../index.js";
|
|
9
|
+
import { setSessionForPause } from "../../shared/debug/pause.js";
|
|
10
|
+
import { parseSessionStateContent } from "../../shared/state/index.js";
|
|
9
11
|
import { getProfilePath, normalizeDomain } from "../core/browser.js";
|
|
10
12
|
import {
|
|
11
13
|
getSessionActionsLogPath,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
getSessionNetworkLogPath,
|
|
15
|
+
getSessionStatePath
|
|
14
16
|
} from "../core/context.js";
|
|
15
17
|
import { getPauseSignalPaths, removeSignalIfExists } from "../core/pause-signals.js";
|
|
16
18
|
import { installSessionTelemetry } from "../core/session-telemetry.js";
|
|
17
19
|
const LIBRETTO_WORKFLOW_BRAND = /* @__PURE__ */ Symbol.for("libretto.workflow");
|
|
18
|
-
const
|
|
20
|
+
const FAILURE_HOLD_POLL_INTERVAL_MS = 250;
|
|
19
21
|
function mirrorStdoutToFile(filePath) {
|
|
20
22
|
const stdout = process.stdout;
|
|
21
23
|
const originalWrite = stdout.write.bind(stdout);
|
|
@@ -31,23 +33,32 @@ function mirrorStdoutToFile(filePath) {
|
|
|
31
33
|
stdout.write = originalWrite;
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
function readSessionStatePid(session) {
|
|
37
|
+
const statePath = getSessionStatePath(session);
|
|
38
|
+
if (!existsSync(statePath)) return null;
|
|
39
|
+
try {
|
|
40
|
+
return parseSessionStateContent(readFileSync(statePath, "utf8"), statePath).pid;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function waitForFailureSessionRelease(args) {
|
|
46
|
+
const { session, expectedPid, logger } = args;
|
|
47
|
+
logger.info("run-failure-session-hold", { session, expectedPid });
|
|
48
|
+
while (true) {
|
|
49
|
+
const currentPid = readSessionStatePid(session);
|
|
50
|
+
if (currentPid !== expectedPid) {
|
|
51
|
+
logger.info("run-failure-session-released", {
|
|
52
|
+
session,
|
|
53
|
+
expectedPid,
|
|
54
|
+
currentPid
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
45
58
|
await new Promise(
|
|
46
|
-
(resolveWait) => setTimeout(resolveWait,
|
|
59
|
+
(resolveWait) => setTimeout(resolveWait, FAILURE_HOLD_POLL_INTERVAL_MS)
|
|
47
60
|
);
|
|
48
61
|
}
|
|
49
|
-
await removeSignalIfExists(resumeSignalPath);
|
|
50
|
-
await removeSignalIfExists(pausedSignalPath);
|
|
51
62
|
}
|
|
52
63
|
function isLoadedLibrettoWorkflow(value) {
|
|
53
64
|
if (!value || typeof value !== "object") return false;
|
|
@@ -57,13 +68,6 @@ function isLoadedLibrettoWorkflow(value) {
|
|
|
57
68
|
function resolveLocalAuthProfilePath(domain) {
|
|
58
69
|
return getProfilePath(normalizeDomain(domain));
|
|
59
70
|
}
|
|
60
|
-
function resolveWorkflowStorageStatePath(workflow) {
|
|
61
|
-
const authProfile = workflow.metadata.authProfile;
|
|
62
|
-
if (authProfile?.type !== "local") {
|
|
63
|
-
return void 0;
|
|
64
|
-
}
|
|
65
|
-
return resolveLocalAuthProfilePath(authProfile.domain);
|
|
66
|
-
}
|
|
67
71
|
function getMissingLocalAuthProfileError(args) {
|
|
68
72
|
const normalizedDomain = normalizeDomain(args.domain);
|
|
69
73
|
return [
|
|
@@ -101,7 +105,24 @@ async function loadWorkflowExport(absolutePath, exportName) {
|
|
|
101
105
|
}
|
|
102
106
|
if (!isLoadedLibrettoWorkflow(targetExport)) {
|
|
103
107
|
throw new Error(
|
|
104
|
-
|
|
108
|
+
[
|
|
109
|
+
`Export "${exportName}" in ${absolutePath} is not a valid Libretto workflow.`,
|
|
110
|
+
"",
|
|
111
|
+
'A workflow must be created using the workflow() function from "libretto":',
|
|
112
|
+
"",
|
|
113
|
+
' import { workflow } from "libretto";',
|
|
114
|
+
"",
|
|
115
|
+
` export const ${exportName} = workflow<InputType, OutputType>(`,
|
|
116
|
+
" {},",
|
|
117
|
+
" async (ctx, input) => {",
|
|
118
|
+
" // ctx.page \u2014 Playwright Page instance",
|
|
119
|
+
" // ctx.logger \u2014 MinimalLogger",
|
|
120
|
+
" // ctx.services \u2014 injected dependencies (generic, default {})",
|
|
121
|
+
" // input \u2014 JSON-serializable input matching InputType",
|
|
122
|
+
" return output; // must match OutputType",
|
|
123
|
+
" },",
|
|
124
|
+
" );"
|
|
125
|
+
].join("\n")
|
|
105
126
|
);
|
|
106
127
|
}
|
|
107
128
|
return targetExport;
|
|
@@ -125,12 +146,12 @@ async function runIntegrationInternal(args, options) {
|
|
|
125
146
|
integrationExport: args.exportName,
|
|
126
147
|
session: args.session
|
|
127
148
|
});
|
|
128
|
-
const
|
|
129
|
-
const storageStatePath =
|
|
130
|
-
if (
|
|
149
|
+
const authProfileDomain = args.authProfileDomain;
|
|
150
|
+
const storageStatePath = authProfileDomain ? resolveLocalAuthProfilePath(authProfileDomain) : void 0;
|
|
151
|
+
if (authProfileDomain && storageStatePath && !existsSync(storageStatePath)) {
|
|
131
152
|
throw new Error(
|
|
132
153
|
getMissingLocalAuthProfileError({
|
|
133
|
-
domain:
|
|
154
|
+
domain: authProfileDomain,
|
|
134
155
|
profilePath: storageStatePath,
|
|
135
156
|
session: args.session
|
|
136
157
|
})
|
|
@@ -154,50 +175,35 @@ async function runIntegrationInternal(args, options) {
|
|
|
154
175
|
appendFileSync(networkLogPath, JSON.stringify(entry) + "\n");
|
|
155
176
|
}
|
|
156
177
|
});
|
|
178
|
+
setSessionForPause(args.session);
|
|
157
179
|
const workflowContext = {
|
|
158
180
|
logger: integrationLogger,
|
|
159
181
|
page: browserSession.page,
|
|
160
|
-
|
|
161
|
-
browser: browserSession.browser,
|
|
162
|
-
session: args.session,
|
|
163
|
-
integrationPath: absolutePath,
|
|
164
|
-
exportName: args.exportName,
|
|
165
|
-
headless: args.headless,
|
|
166
|
-
debug: args.debug,
|
|
167
|
-
pause: async () => {
|
|
168
|
-
const details = {
|
|
169
|
-
sessionName: args.session,
|
|
170
|
-
pausedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
171
|
-
url: browserSession.page.url()
|
|
172
|
-
};
|
|
173
|
-
console.log(`[pause] Paused at ${details.url}`);
|
|
174
|
-
console.log("[pause] Waiting for resume signal...");
|
|
175
|
-
await waitForResumeSignal({
|
|
176
|
-
signalPaths,
|
|
177
|
-
session: args.session,
|
|
178
|
-
details,
|
|
179
|
-
onPaused: options.onPaused
|
|
180
|
-
});
|
|
181
|
-
console.log("[pause] Resume signal received. Continuing workflow...");
|
|
182
|
-
}
|
|
182
|
+
services: {}
|
|
183
183
|
};
|
|
184
184
|
try {
|
|
185
185
|
try {
|
|
186
186
|
await workflow.run(workflowContext, args.params ?? {});
|
|
187
187
|
} catch (error) {
|
|
188
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
188
189
|
await writeFile(
|
|
189
190
|
signalPaths.failedSignalPath,
|
|
190
191
|
JSON.stringify(
|
|
191
192
|
{
|
|
192
193
|
failedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
193
|
-
message:
|
|
194
|
+
message: errorMessage
|
|
194
195
|
},
|
|
195
196
|
null,
|
|
196
197
|
2
|
|
197
198
|
),
|
|
198
199
|
"utf8"
|
|
199
200
|
);
|
|
200
|
-
|
|
201
|
+
await waitForFailureSessionRelease({
|
|
202
|
+
session: args.session,
|
|
203
|
+
expectedPid: process.pid,
|
|
204
|
+
logger
|
|
205
|
+
});
|
|
206
|
+
return { status: "failed-held" };
|
|
201
207
|
}
|
|
202
208
|
await writeFile(
|
|
203
209
|
signalPaths.completedSignalPath,
|
|
@@ -211,10 +217,9 @@ async function runIntegrationInternal(args, options) {
|
|
|
211
217
|
await browserSession.close();
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
|
-
async function runIntegrationFromFileInWorker(args, logger
|
|
220
|
+
async function runIntegrationFromFileInWorker(args, logger) {
|
|
215
221
|
return await runIntegrationInternal(args, {
|
|
216
|
-
logger
|
|
217
|
-
onPaused
|
|
222
|
+
logger
|
|
218
223
|
});
|
|
219
224
|
}
|
|
220
225
|
export {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const RunIntegrationWorkerRequestSchema = z.object({
|
|
3
|
+
integrationPath: z.string().min(1),
|
|
4
|
+
exportName: z.string().min(1),
|
|
5
|
+
session: z.string().min(1),
|
|
6
|
+
params: z.unknown(),
|
|
7
|
+
headless: z.boolean(),
|
|
8
|
+
authProfileDomain: z.string().optional()
|
|
9
|
+
});
|
|
10
|
+
export {
|
|
11
|
+
RunIntegrationWorkerRequestSchema
|
|
12
|
+
};
|