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.
- package/README.md +4 -0
- package/README.template.md +4 -0
- package/dist/cli/cli.js +4 -3
- package/dist/cli/commands/ai.js +3 -2
- package/dist/cli/commands/browser.js +17 -17
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +20 -34
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +81 -9
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/core/ai-model.js +6 -3
- package/dist/cli/core/browser.js +300 -121
- package/dist/cli/core/config.js +4 -2
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +535 -89
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +72 -6
- package/dist/cli/core/experiments.js +66 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/snapshot-analyzer.js +4 -3
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/router.js +4 -1
- package/dist/shared/debug/pause-handler.d.ts +9 -0
- package/dist/shared/debug/pause-handler.js +15 -0
- package/dist/shared/debug/pause.d.ts +1 -2
- package/dist/shared/debug/pause.js +13 -36
- package/dist/shared/ipc/child-process-transport.d.ts +7 -0
- package/dist/shared/ipc/child-process-transport.js +60 -0
- package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/child-process-transport.spec.js +68 -0
- package/dist/shared/ipc/ipc.d.ts +46 -0
- package/dist/shared/ipc/ipc.js +165 -0
- package/dist/shared/ipc/ipc.spec.d.ts +2 -0
- package/dist/shared/ipc/ipc.spec.js +114 -0
- package/dist/shared/ipc/socket-transport.d.ts +9 -0
- package/dist/shared/ipc/socket-transport.js +143 -0
- package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/socket-transport.spec.js +117 -0
- package/dist/shared/package-manager.d.ts +7 -0
- package/dist/shared/package-manager.js +60 -0
- package/dist/shared/paths/paths.d.ts +1 -8
- package/dist/shared/paths/paths.js +1 -49
- package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
- package/dist/shared/snapshot/capture-snapshot.js +463 -0
- package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
- package/dist/shared/snapshot/diff-snapshots.js +358 -0
- package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
- package/dist/shared/snapshot/render-snapshot.js +651 -0
- package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
- package/dist/shared/snapshot/snapshot.spec.js +333 -0
- package/dist/shared/snapshot/types.d.ts +40 -0
- package/dist/shared/snapshot/types.js +0 -0
- package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
- package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +1 -0
- package/docs/experiments.md +67 -0
- package/package.json +4 -2
- package/skills/libretto/SKILL.md +3 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +4 -3
- package/src/cli/commands/ai.ts +3 -2
- package/src/cli/commands/browser.ts +13 -11
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +18 -36
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +99 -11
- package/src/cli/commands/status.ts +5 -4
- package/src/cli/core/ai-model.ts +6 -3
- package/src/cli/core/browser.ts +369 -147
- package/src/cli/core/config.ts +3 -1
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +686 -106
- package/src/cli/core/daemon/ipc.ts +330 -214
- package/src/cli/core/daemon/snapshot.ts +106 -8
- package/src/cli/core/experiments.ts +85 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/snapshot-analyzer.ts +4 -3
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +85 -0
- package/src/cli/router.ts +4 -1
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- package/src/shared/ipc/AGENTS.md +24 -0
- package/src/shared/ipc/child-process-transport.spec.ts +86 -0
- package/src/shared/ipc/child-process-transport.ts +96 -0
- package/src/shared/ipc/ipc.spec.ts +161 -0
- package/src/shared/ipc/ipc.ts +288 -0
- package/src/shared/ipc/socket-transport.spec.ts +141 -0
- package/src/shared/ipc/socket-transport.ts +189 -0
- package/src/shared/package-manager.ts +76 -0
- package/src/shared/paths/paths.ts +0 -72
- package/src/shared/snapshot/capture-snapshot.ts +615 -0
- package/src/shared/snapshot/diff-snapshots.ts +579 -0
- package/src/shared/snapshot/render-snapshot.ts +962 -0
- package/src/shared/snapshot/snapshot.spec.ts +388 -0
- package/src/shared/snapshot/types.ts +43 -0
- package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/daemon/index.js +0 -16
- package/dist/cli/core/daemon/spawn.js +0 -90
- package/dist/cli/core/pause-signals.js +0 -29
- package/dist/cli/workers/run-integration-runtime.js +0 -235
- package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
- package/dist/cli/workers/run-integration-worker.js +0 -64
- package/src/cli/core/daemon/index.ts +0 -24
- package/src/cli/core/daemon/spawn.ts +0 -171
- package/src/cli/core/pause-signals.ts +0 -35
- package/src/cli/workers/run-integration-runtime.ts +0 -326
- package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
- package/src/cli/workers/run-integration-worker.ts +0 -72
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
|
|
2
|
+
import {
|
|
3
|
+
getAbsoluteIntegrationPath,
|
|
4
|
+
installHeadedWorkflowVisualization,
|
|
5
|
+
loadDefaultWorkflow
|
|
6
|
+
} from "../workflow-runtime.js";
|
|
7
|
+
class WorkflowController {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
status = { state: "idle" };
|
|
12
|
+
pendingPause;
|
|
13
|
+
started = false;
|
|
14
|
+
start(workflowConfig) {
|
|
15
|
+
if (this.started) {
|
|
16
|
+
throw new Error("Workflow controller has already started.");
|
|
17
|
+
}
|
|
18
|
+
this.started = true;
|
|
19
|
+
this.status = { state: "running" };
|
|
20
|
+
void this.run(workflowConfig);
|
|
21
|
+
}
|
|
22
|
+
pause(args) {
|
|
23
|
+
if (this.pendingPause) {
|
|
24
|
+
throw new Error("Workflow is already paused.");
|
|
25
|
+
}
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
this.pendingPause = { resolve };
|
|
28
|
+
this.status = { state: "paused", ...args };
|
|
29
|
+
this.config.onOutcome?.(this.status);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
resume() {
|
|
33
|
+
if (!this.pendingPause) {
|
|
34
|
+
throw new Error("Workflow is not paused.");
|
|
35
|
+
}
|
|
36
|
+
const pendingPause = this.pendingPause;
|
|
37
|
+
this.pendingPause = void 0;
|
|
38
|
+
this.status = { state: "running" };
|
|
39
|
+
pendingPause.resolve();
|
|
40
|
+
}
|
|
41
|
+
getStatus() {
|
|
42
|
+
return this.status;
|
|
43
|
+
}
|
|
44
|
+
async run(workflowConfig) {
|
|
45
|
+
const restoreOutput = this.captureProcessOutput();
|
|
46
|
+
try {
|
|
47
|
+
const absolutePath = getAbsoluteIntegrationPath(
|
|
48
|
+
workflowConfig.integrationPath
|
|
49
|
+
);
|
|
50
|
+
const workflow = workflowConfig.loadedWorkflow ?? await loadDefaultWorkflow(absolutePath);
|
|
51
|
+
const workflowLogger = this.config.logger.withScope("integration-run", {
|
|
52
|
+
integrationPath: absolutePath,
|
|
53
|
+
workflowName: workflow.name,
|
|
54
|
+
session: this.config.session
|
|
55
|
+
});
|
|
56
|
+
console.log(
|
|
57
|
+
`Running workflow "${workflow.name}" from ${absolutePath} (${this.config.headed ? "headed" : "headless"})...`
|
|
58
|
+
);
|
|
59
|
+
if (this.config.headed && workflowConfig.visualize !== false) {
|
|
60
|
+
await installHeadedWorkflowVisualization({
|
|
61
|
+
context: this.config.context,
|
|
62
|
+
logger: workflowLogger
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
await this.config.context.addInitScript(() => {
|
|
66
|
+
globalThis.__name = (target, value) => Object.defineProperty(target, "name", {
|
|
67
|
+
value,
|
|
68
|
+
configurable: true
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const workflowContext = {
|
|
72
|
+
session: this.config.session,
|
|
73
|
+
page: this.config.page
|
|
74
|
+
};
|
|
75
|
+
const uninstallPauseHandler = installPauseHandler(
|
|
76
|
+
(pauseArgs) => this.pause({
|
|
77
|
+
...pauseArgs,
|
|
78
|
+
url: this.config.page.isClosed() ? void 0 : this.config.page.url()
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
try {
|
|
82
|
+
await workflow.run(workflowContext, workflowConfig.params ?? {});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
this.emitOutcome({
|
|
85
|
+
state: "finished",
|
|
86
|
+
result: "failed",
|
|
87
|
+
message: error instanceof Error ? error.message : String(error),
|
|
88
|
+
phase: "workflow"
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
} finally {
|
|
92
|
+
uninstallPauseHandler();
|
|
93
|
+
}
|
|
94
|
+
this.emitOutcome({
|
|
95
|
+
state: "finished",
|
|
96
|
+
result: "completed",
|
|
97
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.emitOutcome({
|
|
101
|
+
state: "finished",
|
|
102
|
+
result: "failed",
|
|
103
|
+
message: error instanceof Error ? error.message : String(error),
|
|
104
|
+
phase: "setup"
|
|
105
|
+
});
|
|
106
|
+
} finally {
|
|
107
|
+
restoreOutput();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
emitOutcome(outcome) {
|
|
111
|
+
this.resolvePendingPause();
|
|
112
|
+
this.status = outcome;
|
|
113
|
+
this.config.onOutcome?.(outcome);
|
|
114
|
+
}
|
|
115
|
+
resolvePendingPause() {
|
|
116
|
+
const pendingPause = this.pendingPause;
|
|
117
|
+
if (!pendingPause) return;
|
|
118
|
+
this.pendingPause = void 0;
|
|
119
|
+
pendingPause.resolve();
|
|
120
|
+
}
|
|
121
|
+
captureProcessOutput() {
|
|
122
|
+
const stdout = process.stdout;
|
|
123
|
+
const stderr = process.stderr;
|
|
124
|
+
const originalStdoutWrite = stdout.write;
|
|
125
|
+
const originalStderrWrite = stderr.write;
|
|
126
|
+
stdout.write = ((...writeArgs) => {
|
|
127
|
+
const [chunk] = writeArgs;
|
|
128
|
+
this.config.onLog?.({ stream: "stdout", text: chunkToString(chunk) });
|
|
129
|
+
return Reflect.apply(originalStdoutWrite, stdout, writeArgs);
|
|
130
|
+
});
|
|
131
|
+
stderr.write = ((...writeArgs) => {
|
|
132
|
+
const [chunk] = writeArgs;
|
|
133
|
+
this.config.onLog?.({ stream: "stderr", text: chunkToString(chunk) });
|
|
134
|
+
return Reflect.apply(originalStderrWrite, stderr, writeArgs);
|
|
135
|
+
});
|
|
136
|
+
return () => {
|
|
137
|
+
stdout.write = originalStdoutWrite;
|
|
138
|
+
stderr.write = originalStderrWrite;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function chunkToString(chunk) {
|
|
143
|
+
return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
|
144
|
+
}
|
|
145
|
+
export {
|
|
146
|
+
WorkflowController
|
|
147
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cwd } from "node:process";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { instrumentContext } from "../../index.js";
|
|
6
|
+
import {
|
|
7
|
+
getDefaultWorkflowFromModuleExports,
|
|
8
|
+
getWorkflowsFromModuleExports
|
|
9
|
+
} from "../../shared/workflow/workflow.js";
|
|
10
|
+
const TSCONFIG_HINT = "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
|
|
11
|
+
function isTsxCompileError(error) {
|
|
12
|
+
return error instanceof Error && (error.name === "TransformError" || error.message.startsWith("Cannot resolve tsconfig at path:"));
|
|
13
|
+
}
|
|
14
|
+
function getAbsoluteIntegrationPath(integrationPath) {
|
|
15
|
+
const absolutePath = isAbsolute(integrationPath) ? integrationPath : resolve(cwd(), integrationPath);
|
|
16
|
+
if (!existsSync(absolutePath)) {
|
|
17
|
+
throw new Error(`Integration file does not exist: ${absolutePath}`);
|
|
18
|
+
}
|
|
19
|
+
return absolutePath;
|
|
20
|
+
}
|
|
21
|
+
async function loadDefaultWorkflow(absolutePath) {
|
|
22
|
+
let loadedModule;
|
|
23
|
+
try {
|
|
24
|
+
loadedModule = await import(pathToFileURL(absolutePath).href);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
const compileHint = isTsxCompileError(error) ? `
|
|
28
|
+
${TSCONFIG_HINT}` : absolutePath.endsWith(".ts") || absolutePath.endsWith(".tsx") ? `
|
|
29
|
+
${TSCONFIG_HINT}` : "";
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Failed to import integration module at ${absolutePath}: ${message}${compileHint}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const defaultWorkflow = getDefaultWorkflowFromModuleExports(loadedModule);
|
|
35
|
+
if (defaultWorkflow) {
|
|
36
|
+
return defaultWorkflow;
|
|
37
|
+
}
|
|
38
|
+
const availableWorkflowNames = getWorkflowsFromModuleExports(loadedModule).map(
|
|
39
|
+
(candidate) => candidate.name
|
|
40
|
+
);
|
|
41
|
+
if (availableWorkflowNames.length === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`No default-exported workflow found in ${absolutePath}. Export the workflow with \`export default workflow("name", handler)\`.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No default-exported workflow found in ${absolutePath}. libretto run only uses the file's default export. Available named workflows: ${availableWorkflowNames.join(", ")}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
async function installHeadedWorkflowVisualization(args) {
|
|
51
|
+
await (args.instrument ?? instrumentContext)(args.context, {
|
|
52
|
+
visualize: true,
|
|
53
|
+
logger: args.logger
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
getAbsoluteIntegrationPath,
|
|
58
|
+
installHeadedWorkflowVisualization,
|
|
59
|
+
loadDefaultWorkflow
|
|
60
|
+
};
|
package/dist/cli/router.js
CHANGED
|
@@ -4,13 +4,16 @@ 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
|
const cliRoutes = {
|
|
12
14
|
...browserCommands,
|
|
13
15
|
deploy: deployCommand,
|
|
16
|
+
experiments: experimentsCommand,
|
|
14
17
|
...executionCommands,
|
|
15
18
|
ai: aiCommands,
|
|
16
19
|
auth: authCommands,
|
|
@@ -20,7 +23,7 @@ const cliRoutes = {
|
|
|
20
23
|
snapshot: snapshotCommand
|
|
21
24
|
};
|
|
22
25
|
function createCLIApp() {
|
|
23
|
-
return SimpleCLI.define(
|
|
26
|
+
return SimpleCLI.define(librettoCommand(), cliRoutes);
|
|
24
27
|
}
|
|
25
28
|
export {
|
|
26
29
|
cliRoutes,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type ActivePauseHandler = (args: {
|
|
2
|
+
session: string;
|
|
3
|
+
pausedAt: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
}) => Promise<void>;
|
|
6
|
+
declare function installPauseHandler(handler: ActivePauseHandler): () => void;
|
|
7
|
+
declare function getActivePauseHandler(): ActivePauseHandler | undefined;
|
|
8
|
+
|
|
9
|
+
export { type ActivePauseHandler, getActivePauseHandler, installPauseHandler };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let activePauseHandler;
|
|
2
|
+
function installPauseHandler(handler) {
|
|
3
|
+
const previousHandler = activePauseHandler;
|
|
4
|
+
activePauseHandler = handler;
|
|
5
|
+
return () => {
|
|
6
|
+
activePauseHandler = previousHandler;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function getActivePauseHandler() {
|
|
10
|
+
return activePauseHandler;
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
getActivePauseHandler,
|
|
14
|
+
installPauseHandler
|
|
15
|
+
};
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Standalone pause function.
|
|
3
3
|
*
|
|
4
4
|
* - In production (`NODE_ENV === "production"`), returns immediately (no-op).
|
|
5
|
-
* - Otherwise,
|
|
6
|
-
* using the same file-based mechanism as the CLI runner.
|
|
5
|
+
* - Otherwise, delegates to the active Libretto workflow runtime pause handler.
|
|
7
6
|
*
|
|
8
7
|
* Import directly: `import { pause } from "libretto";`
|
|
9
8
|
*/
|
|
@@ -1,21 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { getSessionDir } from "../../cli/core/context.js";
|
|
4
|
-
import {
|
|
5
|
-
getPauseSignalPaths,
|
|
6
|
-
removeSignalIfExists
|
|
7
|
-
} from "../../cli/core/pause-signals.js";
|
|
8
|
-
import { listRunningSessions } from "../../cli/core/session.js";
|
|
1
|
+
import { getActivePauseHandler } from "./pause-handler.js";
|
|
2
|
+
import { librettoCommand } from "../package-manager.js";
|
|
9
3
|
function throwMissingSessionError() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
lines.push("", "Running sessions:");
|
|
14
|
-
for (const s of runningSessions) {
|
|
15
|
-
lines.push(` ${s.session}`);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
throw new Error(lines.join("\n"));
|
|
4
|
+
throw new Error(
|
|
5
|
+
`pause(session) requires a non-empty session ID. Pass ctx.session from inside your workflow: await pause(ctx.session). To list running sessions, run: ${librettoCommand("status")}.`
|
|
6
|
+
);
|
|
19
7
|
}
|
|
20
8
|
async function pause(session) {
|
|
21
9
|
if (process.env.NODE_ENV === "production") {
|
|
@@ -24,27 +12,16 @@ async function pause(session) {
|
|
|
24
12
|
if (typeof session !== "string" || session.trim().length === 0) {
|
|
25
13
|
throwMissingSessionError();
|
|
26
14
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const details = {
|
|
32
|
-
sessionName: session,
|
|
33
|
-
pausedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34
|
-
url: "unknown"
|
|
35
|
-
};
|
|
36
|
-
await writeFile(pausedSignalPath, JSON.stringify(details, null, 2), "utf8");
|
|
37
|
-
console.log(`[pause] Paused (session: ${session})`);
|
|
38
|
-
console.log("[pause] Waiting for resume signal...");
|
|
39
|
-
const RESUME_POLL_INTERVAL_MS = 250;
|
|
40
|
-
while (!existsSync(resumeSignalPath)) {
|
|
41
|
-
await new Promise(
|
|
42
|
-
(resolve) => setTimeout(resolve, RESUME_POLL_INTERVAL_MS)
|
|
15
|
+
const handler = getActivePauseHandler();
|
|
16
|
+
if (!handler) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`pause(session) can only suspend an active Libretto workflow. Run the workflow with ${librettoCommand("run <integrationFile>")} and call pause(ctx.session) from inside the workflow.`
|
|
43
19
|
);
|
|
44
20
|
}
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
21
|
+
await handler({
|
|
22
|
+
session,
|
|
23
|
+
pausedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
24
|
+
});
|
|
48
25
|
}
|
|
49
26
|
export {
|
|
50
27
|
pause
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ChildProcess } from 'node:child_process';
|
|
2
|
+
import { IpcTransport, IpcProtocolMessage } from './ipc.js';
|
|
3
|
+
|
|
4
|
+
declare function createChildProcessIpcTransport(child: ChildProcess): IpcTransport<IpcProtocolMessage>;
|
|
5
|
+
declare function createParentProcessIpcTransport(): IpcTransport<IpcProtocolMessage>;
|
|
6
|
+
|
|
7
|
+
export { createChildProcessIpcTransport, createParentProcessIpcTransport };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function createChildProcessIpcTransport(child) {
|
|
2
|
+
return createProcessIpcTransport(child, () => {
|
|
3
|
+
if (child.connected) child.disconnect();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
function createParentProcessIpcTransport() {
|
|
7
|
+
if (!process.send) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Cannot create child-process IPC transport: process.send is not available. Start the process with an IPC channel."
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return createProcessIpcTransport(process, () => {
|
|
13
|
+
if (process.connected) process.disconnect?.();
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function createProcessIpcTransport(target, close) {
|
|
17
|
+
return {
|
|
18
|
+
send(message) {
|
|
19
|
+
if (!target.send) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Cannot send IPC message: process IPC channel is closed."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
target.send(message);
|
|
25
|
+
},
|
|
26
|
+
listen(callback) {
|
|
27
|
+
const onMessage = (message) => {
|
|
28
|
+
if (isIpcProtocolMessage(message)) callback(message);
|
|
29
|
+
};
|
|
30
|
+
target.on("message", onMessage);
|
|
31
|
+
return () => target.off("message", onMessage);
|
|
32
|
+
},
|
|
33
|
+
onClose(callback) {
|
|
34
|
+
const onDisconnect = () => callback();
|
|
35
|
+
target.on("disconnect", onDisconnect);
|
|
36
|
+
return () => target.off("disconnect", onDisconnect);
|
|
37
|
+
},
|
|
38
|
+
close
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function isIpcProtocolMessage(message) {
|
|
42
|
+
if (!isRecord(message)) return false;
|
|
43
|
+
if (message.type === "ipc-request") {
|
|
44
|
+
return typeof message.id === "string" && typeof message.method === "string" && Array.isArray(message.args);
|
|
45
|
+
}
|
|
46
|
+
if (message.type === "ipc-response") {
|
|
47
|
+
return typeof message.id === "string" && typeof message.method === "string" && (message.error === void 0 || isSerializedError(message.error));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function isSerializedError(value) {
|
|
52
|
+
return isRecord(value) && typeof value.name === "string" && typeof value.message === "string" && (value.stack === void 0 || typeof value.stack === "string");
|
|
53
|
+
}
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return typeof value === "object" && value !== null;
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
createChildProcessIpcTransport,
|
|
59
|
+
createParentProcessIpcTransport
|
|
60
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { expect, test } from "vitest";
|
|
5
|
+
import { createIpcPeer } from "./ipc.js";
|
|
6
|
+
import { createChildProcessIpcTransport } from "./child-process-transport.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
test("throws a clear error without a child-process IPC channel", async () => {
|
|
9
|
+
const modulePath = fileURLToPath(
|
|
10
|
+
new URL("./child-process-transport.ts", import.meta.url)
|
|
11
|
+
);
|
|
12
|
+
await expect(
|
|
13
|
+
execFileAsync(
|
|
14
|
+
process.execPath,
|
|
15
|
+
[
|
|
16
|
+
"--import",
|
|
17
|
+
"tsx",
|
|
18
|
+
"-e",
|
|
19
|
+
`import { createParentProcessIpcTransport } from ${JSON.stringify(modulePath)}; createParentProcessIpcTransport();`
|
|
20
|
+
],
|
|
21
|
+
{ cwd: process.cwd() }
|
|
22
|
+
)
|
|
23
|
+
).rejects.toMatchObject({
|
|
24
|
+
stderr: expect.stringContaining("process.send is not available")
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
test("lets parent and child process call each other", async () => {
|
|
28
|
+
const child = spawn(
|
|
29
|
+
process.execPath,
|
|
30
|
+
["--import", "tsx", "--input-type=module", "-e", getChildFixtureSource()],
|
|
31
|
+
{ stdio: ["ignore", "ignore", "ignore", "ipc"] }
|
|
32
|
+
);
|
|
33
|
+
child.send({ type: "not-an-ipc-protocol-message" });
|
|
34
|
+
const peer = createIpcPeer(
|
|
35
|
+
createChildProcessIpcTransport(child),
|
|
36
|
+
{
|
|
37
|
+
greet(name) {
|
|
38
|
+
return `hello ${name}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
await expect(peer.call.double(21)).resolves.toBe(42);
|
|
43
|
+
await expect(peer.call.askParent("Ada")).resolves.toBe("hello Ada");
|
|
44
|
+
peer.destroy();
|
|
45
|
+
await new Promise((resolve) => child.once("exit", () => resolve()));
|
|
46
|
+
});
|
|
47
|
+
function getChildFixtureSource() {
|
|
48
|
+
const ipcModule = new URL("./ipc.ts", import.meta.url).href;
|
|
49
|
+
const transportModule = new URL(
|
|
50
|
+
"./child-process-transport.ts",
|
|
51
|
+
import.meta.url
|
|
52
|
+
).href;
|
|
53
|
+
return `
|
|
54
|
+
import { createIpcPeer } from ${JSON.stringify(ipcModule)};
|
|
55
|
+
import { createParentProcessIpcTransport } from ${JSON.stringify(transportModule)};
|
|
56
|
+
|
|
57
|
+
const peer = createIpcPeer(createParentProcessIpcTransport(), {
|
|
58
|
+
double(value) {
|
|
59
|
+
return value * 2;
|
|
60
|
+
},
|
|
61
|
+
async askParent(name) {
|
|
62
|
+
return peer.call.greet(name);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
process.on("disconnect", () => peer.destroy());
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
type IpcTransport<T = unknown> = {
|
|
2
|
+
send(message: T): void | Promise<void>;
|
|
3
|
+
listen(callback: (message: T) => void): () => void;
|
|
4
|
+
onClose?(callback: (error?: Error) => void): () => void;
|
|
5
|
+
close?(): void;
|
|
6
|
+
};
|
|
7
|
+
type FunctionMap<T> = {
|
|
8
|
+
[K in keyof T]: (...args: never[]) => unknown;
|
|
9
|
+
};
|
|
10
|
+
type UnwrapPromise<T> = T extends Promise<infer Result> ? Result : T;
|
|
11
|
+
type MaybeAsync<T extends (...args: never[]) => unknown> = T extends (...args: infer Args) => infer Result ? (...args: Args) => UnwrapPromise<Result> | Promise<UnwrapPromise<Result>> : never;
|
|
12
|
+
type IpcPeerHandlers<Local extends FunctionMap<Local>> = {
|
|
13
|
+
[K in keyof Local]: MaybeAsync<Local[K]>;
|
|
14
|
+
};
|
|
15
|
+
type IpcPeerCalls<Remote extends FunctionMap<Remote>> = {
|
|
16
|
+
[K in keyof Remote]: Remote[K] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<UnwrapPromise<Result>> : never;
|
|
17
|
+
};
|
|
18
|
+
type IpcPeer<Remote extends FunctionMap<Remote>> = {
|
|
19
|
+
call: IpcPeerCalls<Remote>;
|
|
20
|
+
destroy(): void;
|
|
21
|
+
};
|
|
22
|
+
type IpcRequestMessage = {
|
|
23
|
+
type: "ipc-request";
|
|
24
|
+
id: string;
|
|
25
|
+
method: string;
|
|
26
|
+
args: unknown[];
|
|
27
|
+
};
|
|
28
|
+
type SerializedError = {
|
|
29
|
+
name: string;
|
|
30
|
+
message: string;
|
|
31
|
+
stack?: string;
|
|
32
|
+
code?: string | number;
|
|
33
|
+
cause?: SerializedError;
|
|
34
|
+
errors?: SerializedError[];
|
|
35
|
+
};
|
|
36
|
+
type IpcResponseMessage = {
|
|
37
|
+
type: "ipc-response";
|
|
38
|
+
id: string;
|
|
39
|
+
method: string;
|
|
40
|
+
data?: unknown;
|
|
41
|
+
error?: SerializedError;
|
|
42
|
+
};
|
|
43
|
+
type IpcProtocolMessage = IpcRequestMessage | IpcResponseMessage;
|
|
44
|
+
declare function createIpcPeer<Remote extends FunctionMap<Remote>, Local extends FunctionMap<Local>>(transport: IpcTransport<IpcProtocolMessage>, handlers: IpcPeerHandlers<Local>): IpcPeer<Remote>;
|
|
45
|
+
|
|
46
|
+
export { type IpcPeer, type IpcPeerCalls, type IpcPeerHandlers, type IpcProtocolMessage, type IpcTransport, createIpcPeer };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
function createIpcPeer(transport, handlers) {
|
|
3
|
+
const pending = /* @__PURE__ */ new Map();
|
|
4
|
+
let destroyed = false;
|
|
5
|
+
const stopListening = transport.listen((message) => {
|
|
6
|
+
if (message.type === "ipc-request") {
|
|
7
|
+
void handleRequest(message);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
handleResponse(message);
|
|
11
|
+
});
|
|
12
|
+
const stopCloseListener = transport.onClose?.((error) => {
|
|
13
|
+
destroy(error ?? new Error("IPC transport closed"));
|
|
14
|
+
});
|
|
15
|
+
async function handleRequest(message) {
|
|
16
|
+
if (destroyed) return;
|
|
17
|
+
const handler = handlers[message.method];
|
|
18
|
+
if (!handler) {
|
|
19
|
+
await sendResponse({
|
|
20
|
+
type: "ipc-response",
|
|
21
|
+
id: message.id,
|
|
22
|
+
method: message.method,
|
|
23
|
+
error: serializeError(
|
|
24
|
+
new Error(`No handler registered for method: ${message.method}`)
|
|
25
|
+
)
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const data = await Promise.resolve(handler(...message.args));
|
|
31
|
+
await sendResponse({
|
|
32
|
+
type: "ipc-response",
|
|
33
|
+
id: message.id,
|
|
34
|
+
method: message.method,
|
|
35
|
+
data
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await sendResponse({
|
|
39
|
+
type: "ipc-response",
|
|
40
|
+
id: message.id,
|
|
41
|
+
method: message.method,
|
|
42
|
+
error: serializeError(error)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function sendResponse(message) {
|
|
47
|
+
try {
|
|
48
|
+
await transport.send(message);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function handleResponse(message) {
|
|
53
|
+
const request = pending.get(message.id);
|
|
54
|
+
if (!request) return;
|
|
55
|
+
pending.delete(message.id);
|
|
56
|
+
if (message.error) {
|
|
57
|
+
request.reject(deserializeRemoteError(request.method, message.error));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
request.resolve(message.data);
|
|
61
|
+
}
|
|
62
|
+
const call = new Proxy({}, {
|
|
63
|
+
get: (_target, method) => {
|
|
64
|
+
if (typeof method !== "string") return void 0;
|
|
65
|
+
return (...args) => {
|
|
66
|
+
if (destroyed) {
|
|
67
|
+
return Promise.reject(new Error("IPC peer destroyed"));
|
|
68
|
+
}
|
|
69
|
+
const id = `${method}-${randomUUID()}`;
|
|
70
|
+
const promise = new Promise((resolve, reject) => {
|
|
71
|
+
pending.set(id, { method, resolve, reject });
|
|
72
|
+
});
|
|
73
|
+
void Promise.resolve(
|
|
74
|
+
transport.send({
|
|
75
|
+
type: "ipc-request",
|
|
76
|
+
id,
|
|
77
|
+
method,
|
|
78
|
+
args
|
|
79
|
+
})
|
|
80
|
+
).catch((error) => {
|
|
81
|
+
const request = pending.get(id);
|
|
82
|
+
if (!request) return;
|
|
83
|
+
pending.delete(id);
|
|
84
|
+
request.reject(error);
|
|
85
|
+
});
|
|
86
|
+
return promise;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
function destroy(error = new Error("IPC peer destroyed")) {
|
|
91
|
+
if (destroyed) return;
|
|
92
|
+
destroyed = true;
|
|
93
|
+
stopListening();
|
|
94
|
+
stopCloseListener?.();
|
|
95
|
+
transport.close?.();
|
|
96
|
+
for (const request of pending.values()) {
|
|
97
|
+
request.reject(error);
|
|
98
|
+
}
|
|
99
|
+
pending.clear();
|
|
100
|
+
}
|
|
101
|
+
return { call, destroy };
|
|
102
|
+
}
|
|
103
|
+
function serializeError(error, seen = /* @__PURE__ */ new WeakSet()) {
|
|
104
|
+
if (typeof error === "object" && error !== null) {
|
|
105
|
+
if (seen.has(error)) {
|
|
106
|
+
return {
|
|
107
|
+
name: "Error",
|
|
108
|
+
message: "[Circular]"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
seen.add(error);
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof Error) {
|
|
114
|
+
const serialized = {
|
|
115
|
+
name: error.name,
|
|
116
|
+
message: error.message,
|
|
117
|
+
stack: error.stack
|
|
118
|
+
};
|
|
119
|
+
const errorWithCode = error;
|
|
120
|
+
if (typeof errorWithCode.code === "string" || typeof errorWithCode.code === "number") {
|
|
121
|
+
serialized.code = errorWithCode.code;
|
|
122
|
+
}
|
|
123
|
+
if (error.cause !== void 0) {
|
|
124
|
+
serialized.cause = serializeError(error.cause, seen);
|
|
125
|
+
}
|
|
126
|
+
if (error instanceof AggregateError) {
|
|
127
|
+
serialized.errors = error.errors.map(
|
|
128
|
+
(aggregateError) => serializeError(aggregateError, seen)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return serialized;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
name: "NonError",
|
|
135
|
+
message: String(error)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function deserializeRemoteError(method, remoteError) {
|
|
139
|
+
const error = deserializeSerializedError(
|
|
140
|
+
remoteError,
|
|
141
|
+
`${method} > ${remoteError.message}`
|
|
142
|
+
);
|
|
143
|
+
error.stack = [new Error(method).stack, remoteError.stack].filter((stack) => typeof stack === "string").join("\n");
|
|
144
|
+
return error;
|
|
145
|
+
}
|
|
146
|
+
function deserializeSerializedError(serialized, message = serialized.message) {
|
|
147
|
+
const cause = serialized.cause ? deserializeSerializedError(serialized.cause) : void 0;
|
|
148
|
+
const error = serialized.name === "AggregateError" && serialized.errors ? new AggregateError(
|
|
149
|
+
serialized.errors.map(
|
|
150
|
+
(aggregateError) => deserializeSerializedError(aggregateError)
|
|
151
|
+
),
|
|
152
|
+
message,
|
|
153
|
+
{ cause }
|
|
154
|
+
) : new Error(message, { cause });
|
|
155
|
+
error.name = serialized.name;
|
|
156
|
+
error.stack = serialized.stack;
|
|
157
|
+
const errorWithCode = error;
|
|
158
|
+
if (serialized.code !== void 0) {
|
|
159
|
+
errorWithCode.code = serialized.code;
|
|
160
|
+
}
|
|
161
|
+
return error;
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
createIpcPeer
|
|
165
|
+
};
|