libretto 0.6.10 → 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
|
@@ -2,51 +2,133 @@ import {
|
|
|
2
2
|
chromium
|
|
3
3
|
} from "playwright";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
|
-
import { appendFileSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { installSessionTelemetry } from "../session-telemetry.js";
|
|
7
|
+
import {
|
|
8
|
+
createIpcPeer
|
|
9
|
+
} from "../../../shared/ipc/ipc.js";
|
|
10
|
+
import {
|
|
11
|
+
createIpcSocketServer,
|
|
12
|
+
listenOnIpcSocket
|
|
13
|
+
} from "../../../shared/ipc/socket-transport.js";
|
|
7
14
|
import {
|
|
8
15
|
createLoggerForSession,
|
|
9
16
|
getSessionDir,
|
|
10
17
|
getSessionNetworkLogPath,
|
|
11
|
-
getSessionActionsLogPath
|
|
18
|
+
getSessionActionsLogPath,
|
|
19
|
+
getSessionProviderClosePath,
|
|
20
|
+
getSessionStatePath
|
|
12
21
|
} from "../context.js";
|
|
13
22
|
import {
|
|
14
|
-
DaemonServer,
|
|
15
23
|
getDaemonSocketPath
|
|
16
24
|
} from "./ipc.js";
|
|
17
25
|
import { wrapPageForActionLogging } from "../telemetry.js";
|
|
26
|
+
import {
|
|
27
|
+
getProfilePath,
|
|
28
|
+
hasProfile,
|
|
29
|
+
normalizeDomain,
|
|
30
|
+
normalizeUrl
|
|
31
|
+
} from "../browser.js";
|
|
18
32
|
import { handlePages } from "./pages.js";
|
|
19
33
|
import { handleExec, handleReadonlyExec } from "./exec.js";
|
|
20
|
-
import { handleSnapshot } from "./snapshot.js";
|
|
34
|
+
import { handleCompactSnapshot, handleSnapshot } from "./snapshot.js";
|
|
35
|
+
import { librettoCommand } from "../../../shared/package-manager.js";
|
|
36
|
+
import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
|
|
37
|
+
import { diffSnapshots } from "../../../shared/snapshot/diff-snapshots.js";
|
|
38
|
+
import {
|
|
39
|
+
installPageStabilityWaiter,
|
|
40
|
+
preparePageStabilityWait,
|
|
41
|
+
waitForPageStable
|
|
42
|
+
} from "../../../shared/snapshot/wait-for-page-stable.js";
|
|
43
|
+
import { getCloudProviderApi } from "../providers/index.js";
|
|
21
44
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
45
|
+
getAbsoluteIntegrationPath,
|
|
46
|
+
loadDefaultWorkflow
|
|
47
|
+
} from "../workflow-runtime.js";
|
|
48
|
+
import { WorkflowController } from "../workflow-runner/runner.js";
|
|
24
49
|
function isOperationalPage(page) {
|
|
25
50
|
const url = page.url();
|
|
26
51
|
return !url.startsWith("devtools://") && !url.startsWith("chrome-error://");
|
|
27
52
|
}
|
|
53
|
+
async function waitForSessionState(session) {
|
|
54
|
+
const deadline = Date.now() + 2e3;
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
if (existsSync(getSessionStatePath(session))) return;
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
58
|
+
}
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Session state was not written before workflow start for "${session}".`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
class UserFacingStartupError extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "UserFacingStartupError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function getMissingLocalAuthProfileError(args) {
|
|
70
|
+
return [
|
|
71
|
+
`Local auth profile not found for domain "${args.normalizedDomain}".`,
|
|
72
|
+
`Expected profile file: ${args.profilePath}`,
|
|
73
|
+
"To create it:",
|
|
74
|
+
` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
|
|
75
|
+
" 2. Log in manually in the browser window.",
|
|
76
|
+
` 3. libretto save ${args.normalizedDomain} --session ${args.session}`
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
function resolveAuthProfileStorageStatePath(args) {
|
|
80
|
+
if (!args.authProfileDomain) return void 0;
|
|
81
|
+
const normalizedDomain = normalizeDomain(
|
|
82
|
+
normalizeUrl(args.authProfileDomain)
|
|
83
|
+
);
|
|
84
|
+
const profilePath = getProfilePath(normalizedDomain);
|
|
85
|
+
if (!hasProfile(normalizedDomain)) {
|
|
86
|
+
throw new UserFacingStartupError(
|
|
87
|
+
getMissingLocalAuthProfileError({
|
|
88
|
+
normalizedDomain,
|
|
89
|
+
profilePath,
|
|
90
|
+
session: args.session
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return profilePath;
|
|
95
|
+
}
|
|
28
96
|
const PROTOCOL_VERSION = 1;
|
|
29
97
|
const REQUEST_TIMEOUT_MS = 6e4;
|
|
30
98
|
class BrowserDaemon {
|
|
31
|
-
constructor(session, externallyManaged, browser, context, page,
|
|
99
|
+
constructor(session, experiments, externallyManaged, browser, context, page, logger, providerSession) {
|
|
32
100
|
this.session = session;
|
|
101
|
+
this.experiments = experiments;
|
|
33
102
|
this.externallyManaged = externallyManaged;
|
|
34
103
|
this.browser = browser;
|
|
35
104
|
this.context = context;
|
|
36
105
|
this.page = page;
|
|
37
|
-
this.
|
|
106
|
+
this.providerSession = providerSession;
|
|
38
107
|
this.logger = logger.withScope("child");
|
|
39
108
|
}
|
|
40
109
|
logger;
|
|
41
110
|
execState = {};
|
|
42
111
|
pageById = /* @__PURE__ */ new Map();
|
|
43
|
-
|
|
112
|
+
shutdownHandlers = [];
|
|
113
|
+
connectedClis = /* @__PURE__ */ new Set();
|
|
114
|
+
workflowController;
|
|
115
|
+
shutdownPromise;
|
|
116
|
+
latestCompactSnapshotByPage = /* @__PURE__ */ new WeakMap();
|
|
44
117
|
trackPage(page) {
|
|
45
118
|
const id = `page-${Math.random().toString(36).slice(2, 5)}`;
|
|
46
119
|
this.pageById.set(id, page);
|
|
47
120
|
page.on("close", () => this.pageById.delete(id));
|
|
48
121
|
return id;
|
|
49
122
|
}
|
|
123
|
+
async installCompactSnapshotWaiter(page) {
|
|
124
|
+
const result = await preparePageStabilityWait(page, { timeoutMs: 1e3 });
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
this.logger.warn("compact-snapshot-waiter-install-incomplete", {
|
|
127
|
+
session: this.session,
|
|
128
|
+
diagnostics: result.diagnostics
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
50
132
|
// ── Shared initialization ──────────────────────────────────────────
|
|
51
133
|
/**
|
|
52
134
|
* Common setup after the mode-specific code has obtained a browser,
|
|
@@ -56,12 +138,15 @@ class BrowserDaemon {
|
|
|
56
138
|
static async initialize(args) {
|
|
57
139
|
const {
|
|
58
140
|
session,
|
|
141
|
+
experiments,
|
|
59
142
|
externallyManaged,
|
|
60
143
|
browser,
|
|
61
144
|
context,
|
|
62
145
|
page,
|
|
63
146
|
initialPages,
|
|
64
|
-
navigateUrl
|
|
147
|
+
navigateUrl,
|
|
148
|
+
readyProvider,
|
|
149
|
+
providerSession
|
|
65
150
|
} = args;
|
|
66
151
|
await mkdir(getSessionDir(session), { recursive: true });
|
|
67
152
|
const networkLogFile = getSessionNetworkLogPath(session);
|
|
@@ -85,34 +170,76 @@ class BrowserDaemon {
|
|
|
85
170
|
error: err instanceof Error ? err.message : String(err)
|
|
86
171
|
});
|
|
87
172
|
}
|
|
173
|
+
if (experiments["compact-snapshot-format"]) {
|
|
174
|
+
await context.addInitScript(installPageStabilityWaiter);
|
|
175
|
+
}
|
|
88
176
|
const socketPath = getDaemonSocketPath(session);
|
|
89
|
-
let handler;
|
|
90
|
-
const ipcServer = new DaemonServer(
|
|
91
|
-
socketPath,
|
|
92
|
-
(request) => handler(request)
|
|
93
|
-
);
|
|
94
177
|
const daemon = new BrowserDaemon(
|
|
95
178
|
session,
|
|
179
|
+
experiments,
|
|
96
180
|
externallyManaged,
|
|
97
181
|
browser,
|
|
98
182
|
context,
|
|
99
183
|
page,
|
|
100
|
-
|
|
101
|
-
|
|
184
|
+
logger,
|
|
185
|
+
providerSession
|
|
102
186
|
);
|
|
103
187
|
for (const p of initialPages) {
|
|
104
188
|
wrapPageForActionLogging(p, session);
|
|
105
189
|
daemon.trackPage(p);
|
|
106
190
|
}
|
|
191
|
+
if (experiments["compact-snapshot-format"]) {
|
|
192
|
+
await Promise.all(
|
|
193
|
+
initialPages.map(
|
|
194
|
+
(initialPage) => daemon.installCompactSnapshotWaiter(initialPage)
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
107
198
|
context.on("page", (newPage) => {
|
|
108
199
|
wrapPageForActionLogging(newPage, session);
|
|
109
200
|
daemon.trackPage(newPage);
|
|
201
|
+
if (experiments["compact-snapshot-format"]) {
|
|
202
|
+
void daemon.installCompactSnapshotWaiter(newPage);
|
|
203
|
+
}
|
|
110
204
|
});
|
|
111
205
|
if (navigateUrl) {
|
|
112
206
|
await page.goto(navigateUrl);
|
|
113
207
|
}
|
|
114
|
-
|
|
115
|
-
|
|
208
|
+
const ipcServer = createIpcSocketServer((transport) => {
|
|
209
|
+
const cli = createIpcPeer(
|
|
210
|
+
transport,
|
|
211
|
+
daemon.createIpcHandlers()
|
|
212
|
+
);
|
|
213
|
+
const stopTracking = transport.onClose?.(() => {
|
|
214
|
+
daemon.connectedClis.delete(cli);
|
|
215
|
+
stopTracking?.();
|
|
216
|
+
});
|
|
217
|
+
daemon.connectedClis.add(cli);
|
|
218
|
+
});
|
|
219
|
+
daemon.registerShutdownHandler(async (options) => {
|
|
220
|
+
if (!options.keepIpcClientsAlive) {
|
|
221
|
+
for (const cli of daemon.connectedClis) {
|
|
222
|
+
cli.destroy();
|
|
223
|
+
}
|
|
224
|
+
daemon.connectedClis.clear();
|
|
225
|
+
}
|
|
226
|
+
if (options.keepIpcClientsAlive) {
|
|
227
|
+
ipcServer.close((error) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
daemon.logger.warn("ipc-server-close-failed", {
|
|
230
|
+
session,
|
|
231
|
+
error
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await new Promise((resolve, reject) => {
|
|
238
|
+
ipcServer.close((error) => error ? reject(error) : resolve());
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
await listenOnIpcSocket(ipcServer, socketPath);
|
|
242
|
+
process.send?.({ type: "ready", socketPath, provider: readyProvider });
|
|
116
243
|
daemon.logger.info("ipc-server-listening", { socketPath });
|
|
117
244
|
browser.on("disconnected", () => {
|
|
118
245
|
void daemon.shutdown("browser-disconnected-exiting", false);
|
|
@@ -120,20 +247,25 @@ class BrowserDaemon {
|
|
|
120
247
|
return daemon;
|
|
121
248
|
}
|
|
122
249
|
// ── Launch mode ────────────────────────────────────────────────────
|
|
123
|
-
static async launchBrowser(
|
|
250
|
+
static async launchBrowser(args) {
|
|
251
|
+
const { session, browser: config } = args;
|
|
124
252
|
const windowPositionArg = config.windowPosition ? `--window-position=${config.windowPosition.x},${config.windowPosition.y}` : void 0;
|
|
125
253
|
const browser = await chromium.launch({
|
|
126
254
|
headless: !config.headed,
|
|
127
255
|
args: [
|
|
128
256
|
"--disable-blink-features=AutomationControlled",
|
|
129
|
-
`--remote-debugging-port=${config.
|
|
257
|
+
...config.remoteDebuggingPort ? [`--remote-debugging-port=${config.remoteDebuggingPort}`] : [],
|
|
130
258
|
"--remote-debugging-address=127.0.0.1",
|
|
131
259
|
"--no-focus-on-check",
|
|
132
260
|
...windowPositionArg ? [windowPositionArg] : []
|
|
133
261
|
]
|
|
134
262
|
});
|
|
263
|
+
const storageStatePath = config.storageStatePath ?? resolveAuthProfileStorageStatePath({
|
|
264
|
+
authProfileDomain: args.workflow?.authProfileDomain,
|
|
265
|
+
session
|
|
266
|
+
});
|
|
135
267
|
const context = await browser.newContext({
|
|
136
|
-
...
|
|
268
|
+
...storageStatePath ? { storageState: storageStatePath } : {},
|
|
137
269
|
viewport: {
|
|
138
270
|
width: config.viewport.width,
|
|
139
271
|
height: config.viewport.height
|
|
@@ -144,136 +276,437 @@ class BrowserDaemon {
|
|
|
144
276
|
page.setDefaultTimeout(3e4);
|
|
145
277
|
page.setDefaultNavigationTimeout(45e3);
|
|
146
278
|
const daemon = await BrowserDaemon.initialize({
|
|
147
|
-
session
|
|
279
|
+
session,
|
|
280
|
+
experiments: args.experiments,
|
|
148
281
|
externallyManaged: false,
|
|
149
282
|
browser,
|
|
150
283
|
context,
|
|
151
284
|
page,
|
|
152
285
|
initialPages: [page],
|
|
153
|
-
navigateUrl: config.
|
|
286
|
+
navigateUrl: config.initialUrl
|
|
154
287
|
});
|
|
155
288
|
daemon.logger.info("child-launched", {
|
|
156
|
-
port: config.
|
|
289
|
+
port: config.remoteDebuggingPort,
|
|
157
290
|
pid: process.pid,
|
|
158
|
-
session
|
|
291
|
+
session
|
|
159
292
|
});
|
|
160
293
|
return daemon;
|
|
161
294
|
}
|
|
162
295
|
// ── Connect mode ───────────────────────────────────────────────────
|
|
163
|
-
static async connectToEndpoint(
|
|
296
|
+
static async connectToEndpoint(args) {
|
|
297
|
+
const { session, browser: config } = args;
|
|
164
298
|
const browser = await chromium.connectOverCDP(config.cdpEndpoint);
|
|
165
299
|
const contexts = browser.contexts();
|
|
166
300
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
167
301
|
const operationalPages = context.pages().filter(isOperationalPage);
|
|
168
302
|
const page = operationalPages.length > 0 ? operationalPages[operationalPages.length - 1] : await context.newPage();
|
|
169
303
|
const daemon = await BrowserDaemon.initialize({
|
|
170
|
-
session
|
|
304
|
+
session,
|
|
305
|
+
experiments: args.experiments,
|
|
171
306
|
externallyManaged: true,
|
|
172
307
|
browser,
|
|
173
308
|
context,
|
|
174
309
|
page,
|
|
175
310
|
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
176
|
-
navigateUrl: config.
|
|
311
|
+
navigateUrl: config.initialUrl
|
|
177
312
|
});
|
|
178
313
|
daemon.logger.info("child-connected", {
|
|
179
314
|
cdpEndpoint: config.cdpEndpoint,
|
|
180
|
-
url: config.
|
|
315
|
+
url: config.initialUrl,
|
|
181
316
|
pid: process.pid,
|
|
182
|
-
session
|
|
317
|
+
session
|
|
183
318
|
});
|
|
184
319
|
return daemon;
|
|
185
320
|
}
|
|
321
|
+
static async connectToProvider(args) {
|
|
322
|
+
const { session, browser: config } = args;
|
|
323
|
+
const provider = getCloudProviderApi(config.providerName);
|
|
324
|
+
const providerSession = await provider.createSession();
|
|
325
|
+
try {
|
|
326
|
+
const browser = await chromium.connectOverCDP(
|
|
327
|
+
providerSession.cdpEndpoint
|
|
328
|
+
);
|
|
329
|
+
const contexts = browser.contexts();
|
|
330
|
+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
331
|
+
const operationalPages = context.pages().filter(isOperationalPage);
|
|
332
|
+
const page = operationalPages.length > 0 ? operationalPages[operationalPages.length - 1] : await context.newPage();
|
|
333
|
+
const daemon = await BrowserDaemon.initialize({
|
|
334
|
+
session,
|
|
335
|
+
experiments: args.experiments,
|
|
336
|
+
externallyManaged: true,
|
|
337
|
+
browser,
|
|
338
|
+
context,
|
|
339
|
+
page,
|
|
340
|
+
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
341
|
+
navigateUrl: config.initialUrl,
|
|
342
|
+
readyProvider: {
|
|
343
|
+
name: config.providerName,
|
|
344
|
+
sessionId: providerSession.sessionId,
|
|
345
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
346
|
+
liveViewUrl: providerSession.liveViewUrl
|
|
347
|
+
},
|
|
348
|
+
providerSession: {
|
|
349
|
+
provider,
|
|
350
|
+
name: config.providerName,
|
|
351
|
+
sessionId: providerSession.sessionId
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
daemon.logger.info("child-provider-connected", {
|
|
355
|
+
provider: config.providerName,
|
|
356
|
+
sessionId: providerSession.sessionId,
|
|
357
|
+
url: config.initialUrl,
|
|
358
|
+
pid: process.pid,
|
|
359
|
+
session
|
|
360
|
+
});
|
|
361
|
+
return daemon;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
await provider.closeSession(providerSession.sessionId);
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
186
367
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
368
|
+
registerShutdownHandler(handler) {
|
|
369
|
+
this.shutdownHandlers.push(handler);
|
|
370
|
+
}
|
|
371
|
+
shutdown(reason, closeBrowser, options = {}) {
|
|
372
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
373
|
+
this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
|
|
374
|
+
return this.shutdownPromise;
|
|
375
|
+
}
|
|
376
|
+
async performShutdown(reason, closeBrowser, options) {
|
|
377
|
+
let replayUrl;
|
|
378
|
+
try {
|
|
379
|
+
this.logger.info(reason, { session: this.session });
|
|
380
|
+
for (const handler of this.shutdownHandlers) {
|
|
381
|
+
await handler(options);
|
|
382
|
+
}
|
|
383
|
+
if (closeBrowser) {
|
|
384
|
+
if (this.externallyManaged) {
|
|
385
|
+
try {
|
|
386
|
+
this.browser._connection?.close();
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
await this.browser.close();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (this.providerSession) {
|
|
394
|
+
const result = await this.providerSession.provider.closeSession(
|
|
395
|
+
this.providerSession.sessionId
|
|
396
|
+
);
|
|
397
|
+
replayUrl = result.replayUrl;
|
|
398
|
+
if (result.replayUrl) {
|
|
399
|
+
this.logger.info("provider-recording", {
|
|
400
|
+
session: this.session,
|
|
401
|
+
provider: this.providerSession.name,
|
|
402
|
+
sessionId: this.providerSession.sessionId,
|
|
403
|
+
replayUrl: result.replayUrl
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
writeFileSync(
|
|
407
|
+
getSessionProviderClosePath(this.session),
|
|
408
|
+
JSON.stringify(
|
|
409
|
+
{
|
|
410
|
+
provider: this.providerSession.name,
|
|
411
|
+
sessionId: this.providerSession.sessionId,
|
|
412
|
+
replayUrl: result.replayUrl
|
|
413
|
+
},
|
|
414
|
+
null,
|
|
415
|
+
2
|
|
416
|
+
),
|
|
417
|
+
"utf8"
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return replayUrl ? { replayUrl } : {};
|
|
421
|
+
} finally {
|
|
422
|
+
if (options.keepIpcClientsAlive) {
|
|
423
|
+
setImmediate(() => {
|
|
424
|
+
for (const cli of this.connectedClis) {
|
|
425
|
+
cli.destroy();
|
|
426
|
+
}
|
|
427
|
+
this.connectedClis.clear();
|
|
428
|
+
});
|
|
197
429
|
}
|
|
198
|
-
} else {
|
|
199
|
-
await this.browser.close();
|
|
200
430
|
}
|
|
201
431
|
}
|
|
202
432
|
// ── Page resolution ────────────────────────────────────────────────
|
|
203
433
|
resolveTargetPage(pageId) {
|
|
204
434
|
if (!pageId) {
|
|
205
|
-
if (this.
|
|
435
|
+
if (this.page.isClosed()) {
|
|
436
|
+
const openPages = Array.from(this.pageById.values());
|
|
437
|
+
if (openPages.length === 1) return openPages[0];
|
|
206
438
|
throw new Error(
|
|
207
|
-
`
|
|
439
|
+
`The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`
|
|
208
440
|
);
|
|
209
441
|
}
|
|
210
|
-
if (this.pageById.size === 1) {
|
|
211
|
-
return this.pageById.values().next().value;
|
|
212
|
-
}
|
|
213
442
|
return this.page;
|
|
214
443
|
}
|
|
215
444
|
const page = this.pageById.get(pageId);
|
|
216
445
|
if (!page) {
|
|
217
446
|
throw new Error(
|
|
218
|
-
`Page "${pageId}" was not found in session "${this.session}". Run "
|
|
447
|
+
`Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`
|
|
219
448
|
);
|
|
220
449
|
}
|
|
221
450
|
return page;
|
|
222
451
|
}
|
|
223
|
-
// ── IPC
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
452
|
+
// ── IPC handlers ───────────────────────────────────────────────────
|
|
453
|
+
createIpcHandlers() {
|
|
454
|
+
return {
|
|
455
|
+
ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
|
|
456
|
+
pages: () => this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
|
|
457
|
+
exec: (args) => this.runExec(args),
|
|
458
|
+
readonlyExec: (args) => this.runReadonlyExec(args),
|
|
459
|
+
snapshot: (args) => this.runSnapshot(args),
|
|
460
|
+
getWorkflowStatus: () => this.getWorkflowStatus(),
|
|
461
|
+
resumeWorkflow: () => this.resumeWorkflow(),
|
|
462
|
+
close: () => this.shutdown("ipc-close", true, {
|
|
463
|
+
keepIpcClientsAlive: true
|
|
464
|
+
})
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
async runSnapshot(args) {
|
|
468
|
+
if (args.mode === "compact") {
|
|
469
|
+
if (!this.experiments["compact-snapshot-format"]) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`The compact-snapshot-format experiment is not enabled for session "${this.session}". Close and reopen the session after running ${librettoCommand("experiments enable compact-snapshot-format")}.`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
const targetPage = this.resolveTargetPage(args.pageId);
|
|
475
|
+
const result = await this.withRequestTimeout(
|
|
476
|
+
() => handleCompactSnapshot(
|
|
477
|
+
targetPage,
|
|
478
|
+
this.session,
|
|
479
|
+
this.logger,
|
|
480
|
+
{
|
|
481
|
+
pageId: args.pageId,
|
|
482
|
+
cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
|
|
483
|
+
useCachedSnapshot: args.useCachedSnapshot
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
);
|
|
487
|
+
if (!args.useCachedSnapshot) {
|
|
488
|
+
this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
|
|
489
|
+
}
|
|
490
|
+
return result;
|
|
227
491
|
}
|
|
492
|
+
return this.withRequestTimeout(
|
|
493
|
+
() => handleSnapshot(
|
|
494
|
+
this.resolveTargetPage(args.pageId),
|
|
495
|
+
this.session,
|
|
496
|
+
this.logger,
|
|
497
|
+
args.pageId
|
|
498
|
+
)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
async withRequestTimeout(operation) {
|
|
228
502
|
let timerId;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
503
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
504
|
+
timerId = setTimeout(
|
|
505
|
+
() => reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
|
|
506
|
+
REQUEST_TIMEOUT_MS
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
try {
|
|
510
|
+
return await Promise.race([operation(), timeout]);
|
|
511
|
+
} finally {
|
|
512
|
+
if (timerId) clearTimeout(timerId);
|
|
513
|
+
}
|
|
240
514
|
}
|
|
241
|
-
async
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
515
|
+
async runExec(args) {
|
|
516
|
+
if (this.experiments["compact-snapshot-format"]) {
|
|
517
|
+
return this.runCompactExec(args);
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const data = await this.withRequestTimeout(
|
|
521
|
+
() => handleExec(
|
|
522
|
+
this.resolveTargetPage(args.pageId),
|
|
523
|
+
args.code,
|
|
249
524
|
this.context,
|
|
250
525
|
this.browser,
|
|
251
526
|
this.execState,
|
|
252
527
|
this.session,
|
|
253
|
-
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
528
|
+
args.visualize
|
|
529
|
+
)
|
|
530
|
+
);
|
|
531
|
+
return { ok: true, data };
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return this.createExecErrorResult(error);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async runCompactExec(args) {
|
|
537
|
+
let targetPage;
|
|
538
|
+
try {
|
|
539
|
+
targetPage = this.resolveTargetPage(args.pageId);
|
|
540
|
+
const page = targetPage;
|
|
541
|
+
const data = await this.withRequestTimeout(async () => {
|
|
542
|
+
const before = this.latestCompactSnapshotByPage.get(page) ?? await snapshot(page);
|
|
543
|
+
const result = await handleExec(
|
|
544
|
+
page,
|
|
545
|
+
args.code,
|
|
546
|
+
this.context,
|
|
547
|
+
this.browser,
|
|
548
|
+
this.execState,
|
|
263
549
|
this.session,
|
|
264
|
-
|
|
265
|
-
request.pageId
|
|
550
|
+
args.visualize
|
|
266
551
|
);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
552
|
+
try {
|
|
553
|
+
const waitResult = await waitForPageStable(page);
|
|
554
|
+
if (!waitResult.ok) {
|
|
555
|
+
this.logger.warn("compact-exec-stability-wait-incomplete", {
|
|
556
|
+
session: this.session,
|
|
557
|
+
pageId: args.pageId,
|
|
558
|
+
diagnostics: waitResult.diagnostics
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
const after = await snapshot(page);
|
|
562
|
+
const snapshotDiff = diffSnapshots(before, after);
|
|
563
|
+
this.latestCompactSnapshotByPage.set(page, after);
|
|
564
|
+
return { ...result, snapshotDiff };
|
|
565
|
+
} catch (error) {
|
|
566
|
+
this.latestCompactSnapshotByPage.delete(page);
|
|
567
|
+
this.logger.warn("compact-exec-diff-failed", {
|
|
568
|
+
session: this.session,
|
|
569
|
+
pageId: args.pageId,
|
|
570
|
+
error: error instanceof Error ? error.message : String(error)
|
|
571
|
+
});
|
|
572
|
+
return result;
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
return { ok: true, data };
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
|
|
578
|
+
return this.createExecErrorResult(error);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async runReadonlyExec(args) {
|
|
582
|
+
try {
|
|
583
|
+
const data = await this.withRequestTimeout(
|
|
584
|
+
() => handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code)
|
|
585
|
+
);
|
|
586
|
+
return { ok: true, data };
|
|
587
|
+
} catch (error) {
|
|
588
|
+
return this.createExecErrorResult(error);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
createExecErrorResult(error) {
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
message: error instanceof Error ? error.message : String(error),
|
|
595
|
+
output: error instanceof Error ? error.output : void 0
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
startWorkflow(args) {
|
|
599
|
+
if (this.workflowController) {
|
|
600
|
+
throw new Error("Workflow controller has already started.");
|
|
601
|
+
}
|
|
602
|
+
this.workflowController = new WorkflowController({
|
|
603
|
+
session: this.session,
|
|
604
|
+
headed: args.headed,
|
|
605
|
+
page: this.page,
|
|
606
|
+
context: this.context,
|
|
607
|
+
logger: this.logger,
|
|
608
|
+
onLog: (event) => {
|
|
609
|
+
this.broadcast("workflowOutput", event);
|
|
610
|
+
},
|
|
611
|
+
onOutcome: (outcome) => {
|
|
612
|
+
if (outcome.state === "paused") {
|
|
613
|
+
this.broadcast("workflowPaused", {
|
|
614
|
+
pausedAt: outcome.pausedAt,
|
|
615
|
+
url: outcome.url
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
this.broadcast(
|
|
620
|
+
"workflowFinished",
|
|
621
|
+
outcome.result === "completed" ? { result: "completed", completedAt: outcome.completedAt } : {
|
|
622
|
+
result: "failed",
|
|
623
|
+
message: outcome.message,
|
|
624
|
+
phase: outcome.phase
|
|
625
|
+
}
|
|
270
626
|
);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
this.workflowController.start({
|
|
630
|
+
integrationPath: args.workflow.integrationPath,
|
|
631
|
+
params: args.workflow.params,
|
|
632
|
+
visualize: args.workflow.visualize,
|
|
633
|
+
loadedWorkflow: args.loadedWorkflow
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
getWorkflowStatus() {
|
|
637
|
+
return this.workflowController?.getStatus() ?? { state: "idle" };
|
|
638
|
+
}
|
|
639
|
+
resumeWorkflow() {
|
|
640
|
+
if (!this.workflowController) {
|
|
641
|
+
throw new Error("Workflow is not paused.");
|
|
642
|
+
}
|
|
643
|
+
this.workflowController.resume();
|
|
644
|
+
}
|
|
645
|
+
async broadcast(name, message) {
|
|
646
|
+
const results = await Promise.allSettled(
|
|
647
|
+
Array.from(this.connectedClis, (cli) => {
|
|
648
|
+
const call = cli.call[name];
|
|
649
|
+
return call(message);
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
for (const result of results) {
|
|
653
|
+
if (result.status === "rejected") {
|
|
654
|
+
this.logger.warn("workflow-event-failed", {
|
|
655
|
+
event: name,
|
|
656
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason)
|
|
657
|
+
});
|
|
658
|
+
}
|
|
271
659
|
}
|
|
272
660
|
}
|
|
273
661
|
}
|
|
274
662
|
async function main() {
|
|
275
663
|
const config = JSON.parse(process.argv[2]);
|
|
276
|
-
const
|
|
664
|
+
const headed = config.browser.kind === "launch" ? config.browser.headed : false;
|
|
665
|
+
let loadedWorkflow;
|
|
666
|
+
if (config.workflow) {
|
|
667
|
+
try {
|
|
668
|
+
loadedWorkflow = await loadDefaultWorkflow(
|
|
669
|
+
getAbsoluteIntegrationPath(config.workflow.integrationPath)
|
|
670
|
+
);
|
|
671
|
+
} catch (error) {
|
|
672
|
+
throw new UserFacingStartupError(
|
|
673
|
+
error instanceof Error ? error.message : String(error)
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const daemon = config.browser.kind === "provider" ? await BrowserDaemon.connectToProvider({
|
|
678
|
+
session: config.session,
|
|
679
|
+
experiments: config.experiments,
|
|
680
|
+
browser: config.browser
|
|
681
|
+
}) : config.browser.kind === "connect" ? await BrowserDaemon.connectToEndpoint({
|
|
682
|
+
session: config.session,
|
|
683
|
+
experiments: config.experiments,
|
|
684
|
+
browser: config.browser
|
|
685
|
+
}) : await BrowserDaemon.launchBrowser({
|
|
686
|
+
session: config.session,
|
|
687
|
+
experiments: config.experiments,
|
|
688
|
+
browser: config.browser,
|
|
689
|
+
workflow: config.workflow
|
|
690
|
+
});
|
|
691
|
+
if (config.workflow) {
|
|
692
|
+
void waitForSessionState(config.session).then(
|
|
693
|
+
() => daemon.startWorkflow({
|
|
694
|
+
workflow: config.workflow,
|
|
695
|
+
headed,
|
|
696
|
+
loadedWorkflow
|
|
697
|
+
})
|
|
698
|
+
).catch((error) => {
|
|
699
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
700
|
+
daemon.logger.error("workflow-failed", {
|
|
701
|
+
error: message
|
|
702
|
+
});
|
|
703
|
+
return daemon.broadcast("workflowFinished", {
|
|
704
|
+
result: "failed",
|
|
705
|
+
message,
|
|
706
|
+
phase: "setup"
|
|
707
|
+
}).finally(() => daemon.shutdown("workflow-start-failed", true));
|
|
708
|
+
});
|
|
709
|
+
}
|
|
277
710
|
process.on("SIGTERM", () => {
|
|
278
711
|
void daemon.shutdown("child-sigterm", true);
|
|
279
712
|
});
|
|
@@ -295,4 +728,17 @@ async function main() {
|
|
|
295
728
|
});
|
|
296
729
|
});
|
|
297
730
|
}
|
|
298
|
-
|
|
731
|
+
function reportStartupError(error) {
|
|
732
|
+
if (error instanceof UserFacingStartupError) {
|
|
733
|
+
process.send?.({
|
|
734
|
+
type: "startup-error",
|
|
735
|
+
message: error.message
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
process.exit(1);
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
await main();
|
|
742
|
+
} catch (error) {
|
|
743
|
+
reportStartupError(error);
|
|
744
|
+
}
|