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
|
@@ -25,30 +25,69 @@ import {
|
|
|
25
25
|
type Page,
|
|
26
26
|
} from "playwright";
|
|
27
27
|
import { mkdir } from "node:fs/promises";
|
|
28
|
-
import { appendFileSync } from "node:fs";
|
|
28
|
+
import { appendFileSync, existsSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { installSessionTelemetry } from "../session-telemetry.js";
|
|
30
|
+
import {
|
|
31
|
+
createIpcPeer,
|
|
32
|
+
type IpcPeer,
|
|
33
|
+
type IpcPeerHandlers,
|
|
34
|
+
} from "../../../shared/ipc/ipc.js";
|
|
35
|
+
import {
|
|
36
|
+
createIpcSocketServer,
|
|
37
|
+
listenOnIpcSocket,
|
|
38
|
+
} from "../../../shared/ipc/socket-transport.js";
|
|
30
39
|
import {
|
|
31
40
|
createLoggerForSession,
|
|
32
41
|
getSessionDir,
|
|
33
42
|
getSessionNetworkLogPath,
|
|
34
43
|
getSessionActionsLogPath,
|
|
44
|
+
getSessionProviderClosePath,
|
|
45
|
+
getSessionStatePath,
|
|
35
46
|
} from "../context.js";
|
|
36
47
|
import type { LoggerApi } from "../../../shared/logger/index.js";
|
|
48
|
+
import type { ExportedLibrettoWorkflow } from "../../../shared/workflow/workflow.js";
|
|
37
49
|
import {
|
|
38
|
-
DaemonServer,
|
|
39
50
|
getDaemonSocketPath,
|
|
40
|
-
type
|
|
51
|
+
type CliToDaemonApi,
|
|
52
|
+
type DaemonCloseResult,
|
|
53
|
+
type DaemonExecOutput,
|
|
54
|
+
type DaemonExecResult,
|
|
55
|
+
type DaemonToCliApi,
|
|
41
56
|
} from "./ipc.js";
|
|
42
57
|
import { wrapPageForActionLogging } from "../telemetry.js";
|
|
58
|
+
import {
|
|
59
|
+
getProfilePath,
|
|
60
|
+
hasProfile,
|
|
61
|
+
normalizeDomain,
|
|
62
|
+
normalizeUrl,
|
|
63
|
+
} from "../browser.js";
|
|
43
64
|
import { handlePages } from "./pages.js";
|
|
44
65
|
import { handleExec, handleReadonlyExec } from "./exec.js";
|
|
45
|
-
import {
|
|
66
|
+
import { handleCompactSnapshot } from "./snapshot.js";
|
|
67
|
+
import { librettoCommand } from "../../../shared/package-manager.js";
|
|
68
|
+
import type { Snapshot } from "../../../shared/snapshot/types.js";
|
|
69
|
+
import { snapshot } from "../../../shared/snapshot/capture-snapshot.js";
|
|
70
|
+
import { diffSnapshots } from "../../../shared/snapshot/diff-snapshots.js";
|
|
71
|
+
import {
|
|
72
|
+
installPageStabilityWaiter,
|
|
73
|
+
preparePageStabilityWait,
|
|
74
|
+
waitForPageStable,
|
|
75
|
+
} from "../../../shared/snapshot/wait-for-page-stable.js";
|
|
46
76
|
import {
|
|
47
|
-
isConnectConfig,
|
|
48
77
|
type DaemonConfig,
|
|
49
|
-
type
|
|
50
|
-
type
|
|
78
|
+
type DaemonBrowserLaunchConfig,
|
|
79
|
+
type DaemonBrowserConnectConfig,
|
|
80
|
+
type DaemonBrowserProviderConfig,
|
|
81
|
+
type DaemonWorkflowConfig,
|
|
51
82
|
} from "./config.js";
|
|
83
|
+
import type { Experiments } from "../experiments.js";
|
|
84
|
+
import { getCloudProviderApi } from "../providers/index.js";
|
|
85
|
+
import type { ProviderApi } from "../providers/types.js";
|
|
86
|
+
import {
|
|
87
|
+
getAbsoluteIntegrationPath,
|
|
88
|
+
loadDefaultWorkflow,
|
|
89
|
+
} from "../workflow-runtime.js";
|
|
90
|
+
import { WorkflowController } from "../workflow-runner/runner.js";
|
|
52
91
|
|
|
53
92
|
function isOperationalPage(page: Page): boolean {
|
|
54
93
|
const url = page.url();
|
|
@@ -56,6 +95,63 @@ function isOperationalPage(page: Page): boolean {
|
|
|
56
95
|
}
|
|
57
96
|
|
|
58
97
|
type TelemetryEntry = Record<string, unknown>;
|
|
98
|
+
type ErrorWithOutput = Error & { output?: DaemonExecOutput };
|
|
99
|
+
type ShutdownOptions = { keepIpcClientsAlive?: boolean };
|
|
100
|
+
type ShutdownHandler = (options: ShutdownOptions) => Promise<void> | void;
|
|
101
|
+
|
|
102
|
+
async function waitForSessionState(session: string): Promise<void> {
|
|
103
|
+
const deadline = Date.now() + 2_000;
|
|
104
|
+
while (Date.now() < deadline) {
|
|
105
|
+
if (existsSync(getSessionStatePath(session))) return;
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
107
|
+
}
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Session state was not written before workflow start for "${session}".`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
class UserFacingStartupError extends Error {
|
|
114
|
+
constructor(message: string) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = "UserFacingStartupError";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getMissingLocalAuthProfileError(args: {
|
|
121
|
+
normalizedDomain: string;
|
|
122
|
+
profilePath: string;
|
|
123
|
+
session: string;
|
|
124
|
+
}): string {
|
|
125
|
+
return [
|
|
126
|
+
`Local auth profile not found for domain "${args.normalizedDomain}".`,
|
|
127
|
+
`Expected profile file: ${args.profilePath}`,
|
|
128
|
+
"To create it:",
|
|
129
|
+
` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
|
|
130
|
+
" 2. Log in manually in the browser window.",
|
|
131
|
+
` 3. libretto save ${args.normalizedDomain} --session ${args.session}`,
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function resolveAuthProfileStorageStatePath(args: {
|
|
136
|
+
authProfileDomain?: string;
|
|
137
|
+
session: string;
|
|
138
|
+
}): string | undefined {
|
|
139
|
+
if (!args.authProfileDomain) return undefined;
|
|
140
|
+
const normalizedDomain = normalizeDomain(
|
|
141
|
+
normalizeUrl(args.authProfileDomain),
|
|
142
|
+
);
|
|
143
|
+
const profilePath = getProfilePath(normalizedDomain);
|
|
144
|
+
if (!hasProfile(normalizedDomain)) {
|
|
145
|
+
throw new UserFacingStartupError(
|
|
146
|
+
getMissingLocalAuthProfileError({
|
|
147
|
+
normalizedDomain,
|
|
148
|
+
profilePath,
|
|
149
|
+
session: args.session,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return profilePath;
|
|
154
|
+
}
|
|
59
155
|
|
|
60
156
|
// ── BrowserDaemon ──────────────────────────────────────────────────────
|
|
61
157
|
|
|
@@ -66,21 +162,29 @@ class BrowserDaemon {
|
|
|
66
162
|
readonly logger: LoggerApi;
|
|
67
163
|
private readonly execState: Record<string, unknown> = {};
|
|
68
164
|
private readonly pageById = new Map<string, Page>();
|
|
165
|
+
private readonly shutdownHandlers: ShutdownHandler[] = [];
|
|
166
|
+
private readonly connectedClis = new Set<IpcPeer<DaemonToCliApi>>();
|
|
167
|
+
private workflowController: WorkflowController | undefined;
|
|
168
|
+
private shutdownPromise: Promise<DaemonCloseResult> | undefined;
|
|
169
|
+
private readonly latestCompactSnapshotByPage = new WeakMap<Page, Snapshot>();
|
|
69
170
|
|
|
70
171
|
private constructor(
|
|
71
172
|
private readonly session: string,
|
|
173
|
+
private readonly experiments: Experiments,
|
|
72
174
|
private readonly externallyManaged: boolean,
|
|
73
175
|
private readonly browser: Browser,
|
|
74
176
|
private readonly context: BrowserContext,
|
|
75
177
|
private readonly page: Page,
|
|
76
|
-
private readonly ipcServer: DaemonServer,
|
|
77
178
|
logger: LoggerApi,
|
|
179
|
+
private readonly providerSession?: {
|
|
180
|
+
provider: ProviderApi;
|
|
181
|
+
name: string;
|
|
182
|
+
sessionId: string;
|
|
183
|
+
},
|
|
78
184
|
) {
|
|
79
185
|
this.logger = logger.withScope("child");
|
|
80
186
|
}
|
|
81
187
|
|
|
82
|
-
private shuttingDown = false;
|
|
83
|
-
|
|
84
188
|
private trackPage(page: Page): string {
|
|
85
189
|
const id = `page-${Math.random().toString(36).slice(2, 5)}`;
|
|
86
190
|
this.pageById.set(id, page);
|
|
@@ -88,6 +192,16 @@ class BrowserDaemon {
|
|
|
88
192
|
return id;
|
|
89
193
|
}
|
|
90
194
|
|
|
195
|
+
private async installCompactSnapshotWaiter(page: Page): Promise<void> {
|
|
196
|
+
const result = await preparePageStabilityWait(page, { timeoutMs: 1_000 });
|
|
197
|
+
if (!result.ok) {
|
|
198
|
+
this.logger.warn("compact-snapshot-waiter-install-incomplete", {
|
|
199
|
+
session: this.session,
|
|
200
|
+
diagnostics: result.diagnostics,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
91
205
|
// ── Shared initialization ──────────────────────────────────────────
|
|
92
206
|
|
|
93
207
|
/**
|
|
@@ -97,6 +211,7 @@ class BrowserDaemon {
|
|
|
97
211
|
*/
|
|
98
212
|
private static async initialize(args: {
|
|
99
213
|
session: string;
|
|
214
|
+
experiments: Experiments;
|
|
100
215
|
externallyManaged: boolean;
|
|
101
216
|
browser: Browser;
|
|
102
217
|
context: BrowserContext;
|
|
@@ -104,15 +219,29 @@ class BrowserDaemon {
|
|
|
104
219
|
initialPages: Page[];
|
|
105
220
|
/** If set, navigate to this URL after telemetry but before starting IPC. */
|
|
106
221
|
navigateUrl?: string;
|
|
222
|
+
readyProvider?: {
|
|
223
|
+
name: string;
|
|
224
|
+
sessionId: string;
|
|
225
|
+
cdpEndpoint: string;
|
|
226
|
+
liveViewUrl?: string;
|
|
227
|
+
};
|
|
228
|
+
providerSession?: {
|
|
229
|
+
provider: ProviderApi;
|
|
230
|
+
name: string;
|
|
231
|
+
sessionId: string;
|
|
232
|
+
};
|
|
107
233
|
}): Promise<BrowserDaemon> {
|
|
108
234
|
const {
|
|
109
235
|
session,
|
|
236
|
+
experiments,
|
|
110
237
|
externallyManaged,
|
|
111
238
|
browser,
|
|
112
239
|
context,
|
|
113
240
|
page,
|
|
114
241
|
initialPages,
|
|
115
242
|
navigateUrl,
|
|
243
|
+
readyProvider,
|
|
244
|
+
providerSession,
|
|
116
245
|
} = args;
|
|
117
246
|
|
|
118
247
|
await mkdir(getSessionDir(session), { recursive: true });
|
|
@@ -142,21 +271,20 @@ class BrowserDaemon {
|
|
|
142
271
|
});
|
|
143
272
|
}
|
|
144
273
|
|
|
145
|
-
|
|
146
|
-
|
|
274
|
+
await context.addInitScript(installPageStabilityWaiter);
|
|
275
|
+
|
|
276
|
+
// IPC server — typed handlers are attached per client connection so one
|
|
277
|
+
// daemon lifetime can serve multiple CLI invocations.
|
|
147
278
|
const socketPath = getDaemonSocketPath(session);
|
|
148
|
-
let handler: (request: DaemonRequest) => Promise<unknown>;
|
|
149
|
-
const ipcServer = new DaemonServer(socketPath, (request) =>
|
|
150
|
-
handler(request),
|
|
151
|
-
);
|
|
152
279
|
const daemon = new BrowserDaemon(
|
|
153
280
|
session,
|
|
281
|
+
experiments,
|
|
154
282
|
externallyManaged,
|
|
155
283
|
browser,
|
|
156
284
|
context,
|
|
157
285
|
page,
|
|
158
|
-
ipcServer,
|
|
159
286
|
logger,
|
|
287
|
+
providerSession,
|
|
160
288
|
);
|
|
161
289
|
|
|
162
290
|
// Action logging and page tracking must be registered before optional
|
|
@@ -165,9 +293,15 @@ class BrowserDaemon {
|
|
|
165
293
|
wrapPageForActionLogging(p, session);
|
|
166
294
|
daemon.trackPage(p);
|
|
167
295
|
}
|
|
296
|
+
await Promise.all(
|
|
297
|
+
initialPages.map((initialPage) =>
|
|
298
|
+
daemon.installCompactSnapshotWaiter(initialPage),
|
|
299
|
+
),
|
|
300
|
+
);
|
|
168
301
|
context.on("page", (newPage) => {
|
|
169
302
|
wrapPageForActionLogging(newPage, session);
|
|
170
303
|
daemon.trackPage(newPage);
|
|
304
|
+
void daemon.installCompactSnapshotWaiter(newPage);
|
|
171
305
|
});
|
|
172
306
|
|
|
173
307
|
// Navigate after telemetry is installed (so we capture the initial
|
|
@@ -177,8 +311,42 @@ class BrowserDaemon {
|
|
|
177
311
|
await page.goto(navigateUrl);
|
|
178
312
|
}
|
|
179
313
|
|
|
180
|
-
|
|
181
|
-
|
|
314
|
+
const ipcServer = createIpcSocketServer((transport) => {
|
|
315
|
+
const cli = createIpcPeer<DaemonToCliApi, CliToDaemonApi>(
|
|
316
|
+
transport,
|
|
317
|
+
daemon.createIpcHandlers(),
|
|
318
|
+
);
|
|
319
|
+
const stopTracking = transport.onClose?.(() => {
|
|
320
|
+
daemon.connectedClis.delete(cli);
|
|
321
|
+
stopTracking?.();
|
|
322
|
+
});
|
|
323
|
+
daemon.connectedClis.add(cli);
|
|
324
|
+
});
|
|
325
|
+
daemon.registerShutdownHandler(async (options) => {
|
|
326
|
+
if (!options.keepIpcClientsAlive) {
|
|
327
|
+
for (const cli of daemon.connectedClis) {
|
|
328
|
+
cli.destroy();
|
|
329
|
+
}
|
|
330
|
+
daemon.connectedClis.clear();
|
|
331
|
+
}
|
|
332
|
+
if (options.keepIpcClientsAlive) {
|
|
333
|
+
ipcServer.close((error) => {
|
|
334
|
+
if (error) {
|
|
335
|
+
daemon.logger.warn("ipc-server-close-failed", {
|
|
336
|
+
session,
|
|
337
|
+
error,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
await new Promise<void>((resolve, reject) => {
|
|
344
|
+
ipcServer.close((error) => (error ? reject(error) : resolve()));
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await listenOnIpcSocket(ipcServer, socketPath);
|
|
349
|
+
process.send?.({ type: "ready", socketPath, provider: readyProvider });
|
|
182
350
|
daemon.logger.info("ipc-server-listening", { socketPath });
|
|
183
351
|
|
|
184
352
|
browser.on("disconnected", () => {
|
|
@@ -190,7 +358,13 @@ class BrowserDaemon {
|
|
|
190
358
|
|
|
191
359
|
// ── Launch mode ────────────────────────────────────────────────────
|
|
192
360
|
|
|
193
|
-
static async launchBrowser(
|
|
361
|
+
static async launchBrowser(args: {
|
|
362
|
+
session: string;
|
|
363
|
+
experiments: Experiments;
|
|
364
|
+
browser: DaemonBrowserLaunchConfig;
|
|
365
|
+
workflow?: DaemonWorkflowConfig;
|
|
366
|
+
}): Promise<BrowserDaemon> {
|
|
367
|
+
const { session, browser: config } = args;
|
|
194
368
|
const windowPositionArg = config.windowPosition
|
|
195
369
|
? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
|
|
196
370
|
: undefined;
|
|
@@ -199,17 +373,24 @@ class BrowserDaemon {
|
|
|
199
373
|
headless: !config.headed,
|
|
200
374
|
args: [
|
|
201
375
|
"--disable-blink-features=AutomationControlled",
|
|
202
|
-
|
|
376
|
+
...(config.remoteDebuggingPort
|
|
377
|
+
? [`--remote-debugging-port=${config.remoteDebuggingPort}`]
|
|
378
|
+
: []),
|
|
203
379
|
"--remote-debugging-address=127.0.0.1",
|
|
204
380
|
"--no-focus-on-check",
|
|
205
381
|
...(windowPositionArg ? [windowPositionArg] : []),
|
|
206
382
|
],
|
|
207
383
|
});
|
|
208
384
|
|
|
385
|
+
const storageStatePath =
|
|
386
|
+
config.storageStatePath ??
|
|
387
|
+
resolveAuthProfileStorageStatePath({
|
|
388
|
+
authProfileDomain: args.workflow?.authProfileDomain,
|
|
389
|
+
session,
|
|
390
|
+
});
|
|
391
|
+
|
|
209
392
|
const context = await browser.newContext({
|
|
210
|
-
...(
|
|
211
|
-
? { storageState: config.storageStatePath }
|
|
212
|
-
: {}),
|
|
393
|
+
...(storageStatePath ? { storageState: storageStatePath } : {}),
|
|
213
394
|
viewport: {
|
|
214
395
|
width: config.viewport.width,
|
|
215
396
|
height: config.viewport.height,
|
|
@@ -223,19 +404,20 @@ class BrowserDaemon {
|
|
|
223
404
|
page.setDefaultNavigationTimeout(45000);
|
|
224
405
|
|
|
225
406
|
const daemon = await BrowserDaemon.initialize({
|
|
226
|
-
session
|
|
407
|
+
session,
|
|
408
|
+
experiments: args.experiments,
|
|
227
409
|
externallyManaged: false,
|
|
228
410
|
browser,
|
|
229
411
|
context,
|
|
230
412
|
page,
|
|
231
413
|
initialPages: [page],
|
|
232
|
-
navigateUrl: config.
|
|
414
|
+
navigateUrl: config.initialUrl,
|
|
233
415
|
});
|
|
234
416
|
|
|
235
417
|
daemon.logger.info("child-launched", {
|
|
236
|
-
port: config.
|
|
418
|
+
port: config.remoteDebuggingPort,
|
|
237
419
|
pid: process.pid,
|
|
238
|
-
session
|
|
420
|
+
session,
|
|
239
421
|
});
|
|
240
422
|
|
|
241
423
|
return daemon;
|
|
@@ -243,9 +425,12 @@ class BrowserDaemon {
|
|
|
243
425
|
|
|
244
426
|
// ── Connect mode ───────────────────────────────────────────────────
|
|
245
427
|
|
|
246
|
-
static async connectToEndpoint(
|
|
247
|
-
|
|
248
|
-
|
|
428
|
+
static async connectToEndpoint(args: {
|
|
429
|
+
session: string;
|
|
430
|
+
experiments: Experiments;
|
|
431
|
+
browser: DaemonBrowserConnectConfig;
|
|
432
|
+
}): Promise<BrowserDaemon> {
|
|
433
|
+
const { session, browser: config } = args;
|
|
249
434
|
const browser = await chromium.connectOverCDP(config.cdpEndpoint);
|
|
250
435
|
|
|
251
436
|
// Discover existing contexts and pages.
|
|
@@ -259,47 +444,168 @@ class BrowserDaemon {
|
|
|
259
444
|
: await context.newPage();
|
|
260
445
|
|
|
261
446
|
const daemon = await BrowserDaemon.initialize({
|
|
262
|
-
session
|
|
447
|
+
session,
|
|
448
|
+
experiments: args.experiments,
|
|
263
449
|
externallyManaged: true,
|
|
264
450
|
browser,
|
|
265
451
|
context,
|
|
266
452
|
page,
|
|
267
|
-
initialPages:
|
|
268
|
-
|
|
269
|
-
navigateUrl: config.url,
|
|
453
|
+
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
454
|
+
navigateUrl: config.initialUrl,
|
|
270
455
|
});
|
|
271
456
|
|
|
272
457
|
daemon.logger.info("child-connected", {
|
|
273
458
|
cdpEndpoint: config.cdpEndpoint,
|
|
274
|
-
url: config.
|
|
459
|
+
url: config.initialUrl,
|
|
275
460
|
pid: process.pid,
|
|
276
|
-
session
|
|
461
|
+
session,
|
|
277
462
|
});
|
|
278
463
|
|
|
279
464
|
return daemon;
|
|
280
465
|
}
|
|
281
466
|
|
|
467
|
+
static async connectToProvider(args: {
|
|
468
|
+
session: string;
|
|
469
|
+
experiments: Experiments;
|
|
470
|
+
browser: DaemonBrowserProviderConfig;
|
|
471
|
+
}): Promise<BrowserDaemon> {
|
|
472
|
+
const { session, browser: config } = args;
|
|
473
|
+
const provider = getCloudProviderApi(config.providerName);
|
|
474
|
+
const providerSession = await provider.createSession();
|
|
475
|
+
try {
|
|
476
|
+
const browser = await chromium.connectOverCDP(
|
|
477
|
+
providerSession.cdpEndpoint,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const contexts = browser.contexts();
|
|
481
|
+
const context =
|
|
482
|
+
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
483
|
+
const operationalPages = context.pages().filter(isOperationalPage);
|
|
484
|
+
const page =
|
|
485
|
+
operationalPages.length > 0
|
|
486
|
+
? operationalPages[operationalPages.length - 1]
|
|
487
|
+
: await context.newPage();
|
|
488
|
+
|
|
489
|
+
const daemon = await BrowserDaemon.initialize({
|
|
490
|
+
session,
|
|
491
|
+
experiments: args.experiments,
|
|
492
|
+
externallyManaged: true,
|
|
493
|
+
browser,
|
|
494
|
+
context,
|
|
495
|
+
page,
|
|
496
|
+
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
497
|
+
navigateUrl: config.initialUrl,
|
|
498
|
+
readyProvider: {
|
|
499
|
+
name: config.providerName,
|
|
500
|
+
sessionId: providerSession.sessionId,
|
|
501
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
502
|
+
liveViewUrl: providerSession.liveViewUrl,
|
|
503
|
+
},
|
|
504
|
+
providerSession: {
|
|
505
|
+
provider,
|
|
506
|
+
name: config.providerName,
|
|
507
|
+
sessionId: providerSession.sessionId,
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
daemon.logger.info("child-provider-connected", {
|
|
512
|
+
provider: config.providerName,
|
|
513
|
+
sessionId: providerSession.sessionId,
|
|
514
|
+
url: config.initialUrl,
|
|
515
|
+
pid: process.pid,
|
|
516
|
+
session,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return daemon;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
await provider.closeSession(providerSession.sessionId);
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
282
526
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
283
527
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
528
|
+
registerShutdownHandler(handler: ShutdownHandler): void {
|
|
529
|
+
this.shutdownHandlers.push(handler);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
shutdown(
|
|
533
|
+
reason: string,
|
|
534
|
+
closeBrowser: boolean,
|
|
535
|
+
options: ShutdownOptions = {},
|
|
536
|
+
): Promise<DaemonCloseResult> {
|
|
537
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
538
|
+
this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
|
|
539
|
+
return this.shutdownPromise;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async performShutdown(
|
|
543
|
+
reason: string,
|
|
544
|
+
closeBrowser: boolean,
|
|
545
|
+
options: ShutdownOptions,
|
|
546
|
+
): Promise<DaemonCloseResult> {
|
|
547
|
+
let replayUrl: string | undefined;
|
|
548
|
+
try {
|
|
549
|
+
this.logger.info(reason, { session: this.session });
|
|
550
|
+
for (const handler of this.shutdownHandlers) {
|
|
551
|
+
await handler(options);
|
|
552
|
+
}
|
|
553
|
+
if (closeBrowser) {
|
|
554
|
+
if (this.externallyManaged) {
|
|
555
|
+
// Playwright does not expose a public "detach from this CDP browser"
|
|
556
|
+
// API. Closing the private connection lets the daemon's event loop
|
|
557
|
+
// drain without asking Playwright to close the externally managed
|
|
558
|
+
// browser/provider session itself.
|
|
559
|
+
try {
|
|
560
|
+
(
|
|
561
|
+
this.browser as unknown as {
|
|
562
|
+
_connection?: { close(): void };
|
|
563
|
+
}
|
|
564
|
+
)._connection?.close();
|
|
565
|
+
} catch {
|
|
566
|
+
// Connection may already be closed.
|
|
296
567
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
568
|
+
} else {
|
|
569
|
+
await this.browser.close();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (this.providerSession) {
|
|
573
|
+
const result = await this.providerSession.provider.closeSession(
|
|
574
|
+
this.providerSession.sessionId,
|
|
575
|
+
);
|
|
576
|
+
replayUrl = result.replayUrl;
|
|
577
|
+
if (result.replayUrl) {
|
|
578
|
+
this.logger.info("provider-recording", {
|
|
579
|
+
session: this.session,
|
|
580
|
+
provider: this.providerSession.name,
|
|
581
|
+
sessionId: this.providerSession.sessionId,
|
|
582
|
+
replayUrl: result.replayUrl,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
writeFileSync(
|
|
586
|
+
getSessionProviderClosePath(this.session),
|
|
587
|
+
JSON.stringify(
|
|
588
|
+
{
|
|
589
|
+
provider: this.providerSession.name,
|
|
590
|
+
sessionId: this.providerSession.sessionId,
|
|
591
|
+
replayUrl: result.replayUrl,
|
|
592
|
+
},
|
|
593
|
+
null,
|
|
594
|
+
2,
|
|
595
|
+
),
|
|
596
|
+
"utf8",
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return replayUrl ? { replayUrl } : {};
|
|
600
|
+
} finally {
|
|
601
|
+
if (options.keepIpcClientsAlive) {
|
|
602
|
+
setImmediate(() => {
|
|
603
|
+
for (const cli of this.connectedClis) {
|
|
604
|
+
cli.destroy();
|
|
605
|
+
}
|
|
606
|
+
this.connectedClis.clear();
|
|
607
|
+
});
|
|
300
608
|
}
|
|
301
|
-
} else {
|
|
302
|
-
await this.browser.close();
|
|
303
609
|
}
|
|
304
610
|
}
|
|
305
611
|
|
|
@@ -307,82 +613,245 @@ class BrowserDaemon {
|
|
|
307
613
|
|
|
308
614
|
private resolveTargetPage(pageId?: string): Page {
|
|
309
615
|
if (!pageId) {
|
|
310
|
-
if (this.
|
|
616
|
+
if (this.page.isClosed()) {
|
|
617
|
+
const openPages = Array.from(this.pageById.values());
|
|
618
|
+
if (openPages.length === 1) return openPages[0];
|
|
311
619
|
throw new Error(
|
|
312
|
-
`
|
|
620
|
+
`The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`,
|
|
313
621
|
);
|
|
314
622
|
}
|
|
315
|
-
// Return the single tracked page rather than `this.page` — the
|
|
316
|
-
// initial page may have been closed and replaced by a new one.
|
|
317
|
-
if (this.pageById.size === 1) {
|
|
318
|
-
return this.pageById.values().next().value!;
|
|
319
|
-
}
|
|
320
623
|
return this.page;
|
|
321
624
|
}
|
|
322
625
|
const page = this.pageById.get(pageId);
|
|
323
626
|
if (!page) {
|
|
324
627
|
throw new Error(
|
|
325
|
-
`Page "${pageId}" was not found in session "${this.session}". Run "
|
|
628
|
+
`Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`,
|
|
326
629
|
);
|
|
327
630
|
}
|
|
328
631
|
return page;
|
|
329
632
|
}
|
|
330
633
|
|
|
331
|
-
// ── IPC
|
|
634
|
+
// ── IPC handlers ───────────────────────────────────────────────────
|
|
635
|
+
|
|
636
|
+
private createIpcHandlers(): IpcPeerHandlers<CliToDaemonApi> {
|
|
637
|
+
return {
|
|
638
|
+
ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
|
|
639
|
+
pages: () =>
|
|
640
|
+
this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
|
|
641
|
+
exec: (args) => this.runExec(args),
|
|
642
|
+
readonlyExec: (args) => this.runReadonlyExec(args),
|
|
643
|
+
snapshot: (args) => this.runSnapshot(args),
|
|
644
|
+
getWorkflowStatus: () => this.getWorkflowStatus(),
|
|
645
|
+
resumeWorkflow: () => this.resumeWorkflow(),
|
|
646
|
+
close: () =>
|
|
647
|
+
this.shutdown("ipc-close", true, {
|
|
648
|
+
keepIpcClientsAlive: true,
|
|
649
|
+
}),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
332
652
|
|
|
333
|
-
private async
|
|
334
|
-
|
|
335
|
-
|
|
653
|
+
private async runSnapshot(
|
|
654
|
+
args: Parameters<CliToDaemonApi["snapshot"]>[0],
|
|
655
|
+
): Promise<ReturnType<CliToDaemonApi["snapshot"]>> {
|
|
656
|
+
const targetPage = this.resolveTargetPage(args.pageId);
|
|
657
|
+
const result = await this.withRequestTimeout(() =>
|
|
658
|
+
handleCompactSnapshot(
|
|
659
|
+
targetPage,
|
|
660
|
+
this.session,
|
|
661
|
+
this.logger,
|
|
662
|
+
{
|
|
663
|
+
pageId: args.pageId,
|
|
664
|
+
cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
|
|
665
|
+
useCachedSnapshot: args.useCachedSnapshot,
|
|
666
|
+
},
|
|
667
|
+
),
|
|
668
|
+
);
|
|
669
|
+
if (!args.useCachedSnapshot) {
|
|
670
|
+
this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
|
|
336
671
|
}
|
|
672
|
+
return result;
|
|
673
|
+
}
|
|
337
674
|
|
|
675
|
+
private async withRequestTimeout<T>(
|
|
676
|
+
operation: () => Promise<T> | T,
|
|
677
|
+
): Promise<T> {
|
|
338
678
|
// All non-ping commands get a timeout guard. The timer is cleared
|
|
339
679
|
// when the command settles to avoid orphaned timers that would
|
|
340
680
|
// keep the event loop alive after shutdown.
|
|
341
|
-
let timerId: ReturnType<typeof setTimeout
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
681
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
682
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
683
|
+
timerId = setTimeout(
|
|
684
|
+
() =>
|
|
685
|
+
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
|
|
686
|
+
REQUEST_TIMEOUT_MS,
|
|
687
|
+
);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
return await Promise.race([operation(), timeout]);
|
|
692
|
+
} finally {
|
|
693
|
+
if (timerId) clearTimeout(timerId);
|
|
694
|
+
}
|
|
354
695
|
}
|
|
355
696
|
|
|
356
|
-
private async
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
697
|
+
private async runExec(
|
|
698
|
+
args: Parameters<CliToDaemonApi["exec"]>[0],
|
|
699
|
+
): Promise<DaemonExecResult> {
|
|
700
|
+
return this.runCompactExec(args);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async runCompactExec(
|
|
704
|
+
args: Parameters<CliToDaemonApi["exec"]>[0],
|
|
705
|
+
): Promise<DaemonExecResult> {
|
|
706
|
+
let targetPage: Page | undefined;
|
|
707
|
+
try {
|
|
708
|
+
targetPage = this.resolveTargetPage(args.pageId);
|
|
709
|
+
const page = targetPage;
|
|
710
|
+
const data = await this.withRequestTimeout(async () => {
|
|
711
|
+
const before =
|
|
712
|
+
this.latestCompactSnapshotByPage.get(page) ?? (await snapshot(page));
|
|
713
|
+
const result = await handleExec(
|
|
714
|
+
page,
|
|
715
|
+
args.code,
|
|
364
716
|
this.context,
|
|
365
717
|
this.browser,
|
|
366
718
|
this.execState,
|
|
367
719
|
this.session,
|
|
368
|
-
|
|
720
|
+
args.visualize,
|
|
369
721
|
);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
const waitResult = await waitForPageStable(page);
|
|
725
|
+
if (!waitResult.ok) {
|
|
726
|
+
this.logger.warn("compact-exec-stability-wait-incomplete", {
|
|
727
|
+
session: this.session,
|
|
728
|
+
pageId: args.pageId,
|
|
729
|
+
diagnostics: waitResult.diagnostics,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const after = await snapshot(page);
|
|
734
|
+
const snapshotDiff = diffSnapshots(before, after);
|
|
735
|
+
this.latestCompactSnapshotByPage.set(page, after);
|
|
736
|
+
return { ...result, snapshotDiff };
|
|
737
|
+
} catch (error) {
|
|
738
|
+
this.latestCompactSnapshotByPage.delete(page);
|
|
739
|
+
this.logger.warn("compact-exec-diff-failed", {
|
|
740
|
+
session: this.session,
|
|
741
|
+
pageId: args.pageId,
|
|
742
|
+
error: error instanceof Error ? error.message : String(error),
|
|
743
|
+
});
|
|
744
|
+
return result;
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
return { ok: true, data };
|
|
748
|
+
} catch (error) {
|
|
749
|
+
if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
|
|
750
|
+
return this.createExecErrorResult(error);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private async runReadonlyExec(
|
|
755
|
+
args: Parameters<CliToDaemonApi["readonlyExec"]>[0],
|
|
756
|
+
): Promise<DaemonExecResult> {
|
|
757
|
+
try {
|
|
758
|
+
const data = await this.withRequestTimeout(() =>
|
|
759
|
+
handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code),
|
|
760
|
+
);
|
|
761
|
+
return { ok: true, data };
|
|
762
|
+
} catch (error) {
|
|
763
|
+
return this.createExecErrorResult(error);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private createExecErrorResult(error: unknown): DaemonExecResult {
|
|
768
|
+
return {
|
|
769
|
+
ok: false,
|
|
770
|
+
message: error instanceof Error ? error.message : String(error),
|
|
771
|
+
output:
|
|
772
|
+
error instanceof Error ? (error as ErrorWithOutput).output : undefined,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
startWorkflow(args: {
|
|
777
|
+
workflow: DaemonWorkflowConfig;
|
|
778
|
+
headed: boolean;
|
|
779
|
+
loadedWorkflow?: ExportedLibrettoWorkflow;
|
|
780
|
+
}): void {
|
|
781
|
+
if (this.workflowController) {
|
|
782
|
+
throw new Error("Workflow controller has already started.");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
this.workflowController = new WorkflowController({
|
|
786
|
+
session: this.session,
|
|
787
|
+
headed: args.headed,
|
|
788
|
+
page: this.page,
|
|
789
|
+
context: this.context,
|
|
790
|
+
logger: this.logger,
|
|
791
|
+
onLog: (event) => {
|
|
792
|
+
void this.broadcast("workflowOutput", event);
|
|
793
|
+
},
|
|
794
|
+
onOutcome: (outcome) => {
|
|
795
|
+
if (outcome.state === "paused") {
|
|
796
|
+
void this.broadcast("workflowPaused", {
|
|
797
|
+
pausedAt: outcome.pausedAt,
|
|
798
|
+
url: outcome.url,
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
void this.broadcast(
|
|
803
|
+
"workflowFinished",
|
|
804
|
+
outcome.result === "completed"
|
|
805
|
+
? { result: "completed", completedAt: outcome.completedAt }
|
|
806
|
+
: {
|
|
807
|
+
result: "failed",
|
|
808
|
+
message: outcome.message,
|
|
809
|
+
phase: outcome.phase,
|
|
810
|
+
},
|
|
385
811
|
);
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
this.workflowController.start({
|
|
815
|
+
integrationPath: args.workflow.integrationPath,
|
|
816
|
+
params: args.workflow.params,
|
|
817
|
+
visualize: args.workflow.visualize,
|
|
818
|
+
loadedWorkflow: args.loadedWorkflow,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
getWorkflowStatus(): ReturnType<WorkflowController["getStatus"]> {
|
|
823
|
+
return this.workflowController?.getStatus() ?? { state: "idle" };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
resumeWorkflow(): void {
|
|
827
|
+
if (!this.workflowController) {
|
|
828
|
+
throw new Error("Workflow is not paused.");
|
|
829
|
+
}
|
|
830
|
+
this.workflowController.resume();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async broadcast<Name extends keyof DaemonToCliApi>(
|
|
834
|
+
name: Name,
|
|
835
|
+
message: Parameters<DaemonToCliApi[Name]>[0],
|
|
836
|
+
): Promise<void> {
|
|
837
|
+
const results = await Promise.allSettled(
|
|
838
|
+
Array.from(this.connectedClis, (cli) => {
|
|
839
|
+
const call = cli.call[name] as (
|
|
840
|
+
message: Parameters<DaemonToCliApi[Name]>[0],
|
|
841
|
+
) => Promise<void>;
|
|
842
|
+
return call(message);
|
|
843
|
+
}),
|
|
844
|
+
);
|
|
845
|
+
for (const result of results) {
|
|
846
|
+
if (result.status === "rejected") {
|
|
847
|
+
this.logger.warn("workflow-event-failed", {
|
|
848
|
+
event: name,
|
|
849
|
+
error:
|
|
850
|
+
result.reason instanceof Error
|
|
851
|
+
? result.reason.message
|
|
852
|
+
: String(result.reason),
|
|
853
|
+
});
|
|
854
|
+
}
|
|
386
855
|
}
|
|
387
856
|
}
|
|
388
857
|
}
|
|
@@ -391,10 +860,65 @@ class BrowserDaemon {
|
|
|
391
860
|
|
|
392
861
|
async function main(): Promise<void> {
|
|
393
862
|
const config = JSON.parse(process.argv[2]) as DaemonConfig;
|
|
863
|
+
const headed =
|
|
864
|
+
config.browser.kind === "launch" ? config.browser.headed : false;
|
|
394
865
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
866
|
+
let loadedWorkflow: ExportedLibrettoWorkflow | undefined;
|
|
867
|
+
if (config.workflow) {
|
|
868
|
+
try {
|
|
869
|
+
loadedWorkflow = await loadDefaultWorkflow(
|
|
870
|
+
getAbsoluteIntegrationPath(config.workflow.integrationPath),
|
|
871
|
+
);
|
|
872
|
+
} catch (error) {
|
|
873
|
+
throw new UserFacingStartupError(
|
|
874
|
+
error instanceof Error ? error.message : String(error),
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const daemon =
|
|
880
|
+
config.browser.kind === "provider"
|
|
881
|
+
? await BrowserDaemon.connectToProvider({
|
|
882
|
+
session: config.session,
|
|
883
|
+
experiments: config.experiments,
|
|
884
|
+
browser: config.browser,
|
|
885
|
+
})
|
|
886
|
+
: config.browser.kind === "connect"
|
|
887
|
+
? await BrowserDaemon.connectToEndpoint({
|
|
888
|
+
session: config.session,
|
|
889
|
+
experiments: config.experiments,
|
|
890
|
+
browser: config.browser,
|
|
891
|
+
})
|
|
892
|
+
: await BrowserDaemon.launchBrowser({
|
|
893
|
+
session: config.session,
|
|
894
|
+
experiments: config.experiments,
|
|
895
|
+
browser: config.browser,
|
|
896
|
+
workflow: config.workflow,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
if (config.workflow) {
|
|
900
|
+
void waitForSessionState(config.session)
|
|
901
|
+
.then(() =>
|
|
902
|
+
daemon.startWorkflow({
|
|
903
|
+
workflow: config.workflow!,
|
|
904
|
+
headed,
|
|
905
|
+
loadedWorkflow,
|
|
906
|
+
}),
|
|
907
|
+
)
|
|
908
|
+
.catch((error) => {
|
|
909
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
910
|
+
daemon.logger.error("workflow-failed", {
|
|
911
|
+
error: message,
|
|
912
|
+
});
|
|
913
|
+
return daemon
|
|
914
|
+
.broadcast("workflowFinished", {
|
|
915
|
+
result: "failed" as const,
|
|
916
|
+
message,
|
|
917
|
+
phase: "setup" as const,
|
|
918
|
+
})
|
|
919
|
+
.finally(() => daemon.shutdown("workflow-start-failed", true));
|
|
920
|
+
});
|
|
921
|
+
}
|
|
398
922
|
|
|
399
923
|
process.on("SIGTERM", () => {
|
|
400
924
|
void daemon.shutdown("child-sigterm", true);
|
|
@@ -426,4 +950,18 @@ async function main(): Promise<void> {
|
|
|
426
950
|
// letting the process exit naturally.
|
|
427
951
|
}
|
|
428
952
|
|
|
429
|
-
|
|
953
|
+
function reportStartupError(error: unknown): never {
|
|
954
|
+
if (error instanceof UserFacingStartupError) {
|
|
955
|
+
process.send?.({
|
|
956
|
+
type: "startup-error",
|
|
957
|
+
message: error.message,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
process.exit(1);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
await main();
|
|
965
|
+
} catch (error) {
|
|
966
|
+
reportStartupError(error);
|
|
967
|
+
}
|