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