libretto 0.6.11 → 0.6.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/README.template.md +4 -0
- package/dist/cli/cli.js +4 -3
- package/dist/cli/commands/ai.js +3 -2
- package/dist/cli/commands/browser.js +17 -17
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +20 -34
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +81 -9
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/core/ai-model.js +6 -3
- package/dist/cli/core/browser.js +300 -121
- package/dist/cli/core/config.js +4 -2
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +535 -89
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +72 -6
- package/dist/cli/core/experiments.js +66 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/snapshot-analyzer.js +4 -3
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/router.js +4 -1
- package/dist/shared/debug/pause-handler.d.ts +9 -0
- package/dist/shared/debug/pause-handler.js +15 -0
- package/dist/shared/debug/pause.d.ts +1 -2
- package/dist/shared/debug/pause.js +13 -36
- package/dist/shared/ipc/child-process-transport.d.ts +7 -0
- package/dist/shared/ipc/child-process-transport.js +60 -0
- package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/child-process-transport.spec.js +68 -0
- package/dist/shared/ipc/ipc.d.ts +46 -0
- package/dist/shared/ipc/ipc.js +165 -0
- package/dist/shared/ipc/ipc.spec.d.ts +2 -0
- package/dist/shared/ipc/ipc.spec.js +114 -0
- package/dist/shared/ipc/socket-transport.d.ts +9 -0
- package/dist/shared/ipc/socket-transport.js +143 -0
- package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/socket-transport.spec.js +117 -0
- package/dist/shared/package-manager.d.ts +7 -0
- package/dist/shared/package-manager.js +60 -0
- package/dist/shared/paths/paths.d.ts +1 -8
- package/dist/shared/paths/paths.js +1 -49
- package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
- package/dist/shared/snapshot/capture-snapshot.js +463 -0
- package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
- package/dist/shared/snapshot/diff-snapshots.js +358 -0
- package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
- package/dist/shared/snapshot/render-snapshot.js +651 -0
- package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
- package/dist/shared/snapshot/snapshot.spec.js +333 -0
- package/dist/shared/snapshot/types.d.ts +40 -0
- package/dist/shared/snapshot/types.js +0 -0
- package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
- package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +1 -0
- package/docs/experiments.md +67 -0
- package/package.json +4 -2
- package/skills/libretto/SKILL.md +3 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +4 -3
- package/src/cli/commands/ai.ts +3 -2
- package/src/cli/commands/browser.ts +13 -11
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +18 -36
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +99 -11
- package/src/cli/commands/status.ts +5 -4
- package/src/cli/core/ai-model.ts +6 -3
- package/src/cli/core/browser.ts +369 -147
- package/src/cli/core/config.ts +3 -1
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +686 -106
- package/src/cli/core/daemon/ipc.ts +330 -214
- package/src/cli/core/daemon/snapshot.ts +106 -8
- package/src/cli/core/experiments.ts +85 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/snapshot-analyzer.ts +4 -3
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +85 -0
- package/src/cli/router.ts +4 -1
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- package/src/shared/ipc/AGENTS.md +24 -0
- package/src/shared/ipc/child-process-transport.spec.ts +86 -0
- package/src/shared/ipc/child-process-transport.ts +96 -0
- package/src/shared/ipc/ipc.spec.ts +161 -0
- package/src/shared/ipc/ipc.ts +288 -0
- package/src/shared/ipc/socket-transport.spec.ts +141 -0
- package/src/shared/ipc/socket-transport.ts +189 -0
- package/src/shared/package-manager.ts +76 -0
- package/src/shared/paths/paths.ts +0 -72
- package/src/shared/snapshot/capture-snapshot.ts +615 -0
- package/src/shared/snapshot/diff-snapshots.ts +579 -0
- package/src/shared/snapshot/render-snapshot.ts +962 -0
- package/src/shared/snapshot/snapshot.spec.ts +388 -0
- package/src/shared/snapshot/types.ts +43 -0
- package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/daemon/index.js +0 -16
- package/dist/cli/core/daemon/spawn.js +0 -90
- package/dist/cli/core/pause-signals.js +0 -29
- package/dist/cli/workers/run-integration-runtime.js +0 -235
- package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
- package/dist/cli/workers/run-integration-worker.js +0 -64
- package/src/cli/core/daemon/index.ts +0 -24
- package/src/cli/core/daemon/spawn.ts +0 -171
- package/src/cli/core/pause-signals.ts +0 -35
- package/src/cli/workers/run-integration-runtime.ts +0 -326
- package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
- package/src/cli/workers/run-integration-worker.ts +0 -72
|
@@ -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 { handleSnapshot } from "./snapshot.js";
|
|
66
|
+
import { handleCompactSnapshot, handleSnapshot } 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,22 @@ class BrowserDaemon {
|
|
|
142
271
|
});
|
|
143
272
|
}
|
|
144
273
|
|
|
145
|
-
|
|
146
|
-
|
|
274
|
+
if (experiments["compact-snapshot-format"]) {
|
|
275
|
+
await context.addInitScript(installPageStabilityWaiter);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// IPC server — typed handlers are attached per client connection so one
|
|
279
|
+
// daemon lifetime can serve multiple CLI invocations.
|
|
147
280
|
const socketPath = getDaemonSocketPath(session);
|
|
148
|
-
let handler: (request: DaemonRequest) => Promise<unknown>;
|
|
149
|
-
const ipcServer = new DaemonServer(socketPath, (request) =>
|
|
150
|
-
handler(request),
|
|
151
|
-
);
|
|
152
281
|
const daemon = new BrowserDaemon(
|
|
153
282
|
session,
|
|
283
|
+
experiments,
|
|
154
284
|
externallyManaged,
|
|
155
285
|
browser,
|
|
156
286
|
context,
|
|
157
287
|
page,
|
|
158
|
-
ipcServer,
|
|
159
288
|
logger,
|
|
289
|
+
providerSession,
|
|
160
290
|
);
|
|
161
291
|
|
|
162
292
|
// Action logging and page tracking must be registered before optional
|
|
@@ -165,9 +295,19 @@ class BrowserDaemon {
|
|
|
165
295
|
wrapPageForActionLogging(p, session);
|
|
166
296
|
daemon.trackPage(p);
|
|
167
297
|
}
|
|
298
|
+
if (experiments["compact-snapshot-format"]) {
|
|
299
|
+
await Promise.all(
|
|
300
|
+
initialPages.map((initialPage) =>
|
|
301
|
+
daemon.installCompactSnapshotWaiter(initialPage),
|
|
302
|
+
),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
168
305
|
context.on("page", (newPage) => {
|
|
169
306
|
wrapPageForActionLogging(newPage, session);
|
|
170
307
|
daemon.trackPage(newPage);
|
|
308
|
+
if (experiments["compact-snapshot-format"]) {
|
|
309
|
+
void daemon.installCompactSnapshotWaiter(newPage);
|
|
310
|
+
}
|
|
171
311
|
});
|
|
172
312
|
|
|
173
313
|
// Navigate after telemetry is installed (so we capture the initial
|
|
@@ -177,8 +317,42 @@ class BrowserDaemon {
|
|
|
177
317
|
await page.goto(navigateUrl);
|
|
178
318
|
}
|
|
179
319
|
|
|
180
|
-
|
|
181
|
-
|
|
320
|
+
const ipcServer = createIpcSocketServer((transport) => {
|
|
321
|
+
const cli = createIpcPeer<DaemonToCliApi, CliToDaemonApi>(
|
|
322
|
+
transport,
|
|
323
|
+
daemon.createIpcHandlers(),
|
|
324
|
+
);
|
|
325
|
+
const stopTracking = transport.onClose?.(() => {
|
|
326
|
+
daemon.connectedClis.delete(cli);
|
|
327
|
+
stopTracking?.();
|
|
328
|
+
});
|
|
329
|
+
daemon.connectedClis.add(cli);
|
|
330
|
+
});
|
|
331
|
+
daemon.registerShutdownHandler(async (options) => {
|
|
332
|
+
if (!options.keepIpcClientsAlive) {
|
|
333
|
+
for (const cli of daemon.connectedClis) {
|
|
334
|
+
cli.destroy();
|
|
335
|
+
}
|
|
336
|
+
daemon.connectedClis.clear();
|
|
337
|
+
}
|
|
338
|
+
if (options.keepIpcClientsAlive) {
|
|
339
|
+
ipcServer.close((error) => {
|
|
340
|
+
if (error) {
|
|
341
|
+
daemon.logger.warn("ipc-server-close-failed", {
|
|
342
|
+
session,
|
|
343
|
+
error,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
await new Promise<void>((resolve, reject) => {
|
|
350
|
+
ipcServer.close((error) => (error ? reject(error) : resolve()));
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await listenOnIpcSocket(ipcServer, socketPath);
|
|
355
|
+
process.send?.({ type: "ready", socketPath, provider: readyProvider });
|
|
182
356
|
daemon.logger.info("ipc-server-listening", { socketPath });
|
|
183
357
|
|
|
184
358
|
browser.on("disconnected", () => {
|
|
@@ -190,7 +364,13 @@ class BrowserDaemon {
|
|
|
190
364
|
|
|
191
365
|
// ── Launch mode ────────────────────────────────────────────────────
|
|
192
366
|
|
|
193
|
-
static async launchBrowser(
|
|
367
|
+
static async launchBrowser(args: {
|
|
368
|
+
session: string;
|
|
369
|
+
experiments: Experiments;
|
|
370
|
+
browser: DaemonBrowserLaunchConfig;
|
|
371
|
+
workflow?: DaemonWorkflowConfig;
|
|
372
|
+
}): Promise<BrowserDaemon> {
|
|
373
|
+
const { session, browser: config } = args;
|
|
194
374
|
const windowPositionArg = config.windowPosition
|
|
195
375
|
? `--window-position=${config.windowPosition.x},${config.windowPosition.y}`
|
|
196
376
|
: undefined;
|
|
@@ -199,17 +379,24 @@ class BrowserDaemon {
|
|
|
199
379
|
headless: !config.headed,
|
|
200
380
|
args: [
|
|
201
381
|
"--disable-blink-features=AutomationControlled",
|
|
202
|
-
|
|
382
|
+
...(config.remoteDebuggingPort
|
|
383
|
+
? [`--remote-debugging-port=${config.remoteDebuggingPort}`]
|
|
384
|
+
: []),
|
|
203
385
|
"--remote-debugging-address=127.0.0.1",
|
|
204
386
|
"--no-focus-on-check",
|
|
205
387
|
...(windowPositionArg ? [windowPositionArg] : []),
|
|
206
388
|
],
|
|
207
389
|
});
|
|
208
390
|
|
|
391
|
+
const storageStatePath =
|
|
392
|
+
config.storageStatePath ??
|
|
393
|
+
resolveAuthProfileStorageStatePath({
|
|
394
|
+
authProfileDomain: args.workflow?.authProfileDomain,
|
|
395
|
+
session,
|
|
396
|
+
});
|
|
397
|
+
|
|
209
398
|
const context = await browser.newContext({
|
|
210
|
-
...(
|
|
211
|
-
? { storageState: config.storageStatePath }
|
|
212
|
-
: {}),
|
|
399
|
+
...(storageStatePath ? { storageState: storageStatePath } : {}),
|
|
213
400
|
viewport: {
|
|
214
401
|
width: config.viewport.width,
|
|
215
402
|
height: config.viewport.height,
|
|
@@ -223,19 +410,20 @@ class BrowserDaemon {
|
|
|
223
410
|
page.setDefaultNavigationTimeout(45000);
|
|
224
411
|
|
|
225
412
|
const daemon = await BrowserDaemon.initialize({
|
|
226
|
-
session
|
|
413
|
+
session,
|
|
414
|
+
experiments: args.experiments,
|
|
227
415
|
externallyManaged: false,
|
|
228
416
|
browser,
|
|
229
417
|
context,
|
|
230
418
|
page,
|
|
231
419
|
initialPages: [page],
|
|
232
|
-
navigateUrl: config.
|
|
420
|
+
navigateUrl: config.initialUrl,
|
|
233
421
|
});
|
|
234
422
|
|
|
235
423
|
daemon.logger.info("child-launched", {
|
|
236
|
-
port: config.
|
|
424
|
+
port: config.remoteDebuggingPort,
|
|
237
425
|
pid: process.pid,
|
|
238
|
-
session
|
|
426
|
+
session,
|
|
239
427
|
});
|
|
240
428
|
|
|
241
429
|
return daemon;
|
|
@@ -243,9 +431,12 @@ class BrowserDaemon {
|
|
|
243
431
|
|
|
244
432
|
// ── Connect mode ───────────────────────────────────────────────────
|
|
245
433
|
|
|
246
|
-
static async connectToEndpoint(
|
|
247
|
-
|
|
248
|
-
|
|
434
|
+
static async connectToEndpoint(args: {
|
|
435
|
+
session: string;
|
|
436
|
+
experiments: Experiments;
|
|
437
|
+
browser: DaemonBrowserConnectConfig;
|
|
438
|
+
}): Promise<BrowserDaemon> {
|
|
439
|
+
const { session, browser: config } = args;
|
|
249
440
|
const browser = await chromium.connectOverCDP(config.cdpEndpoint);
|
|
250
441
|
|
|
251
442
|
// Discover existing contexts and pages.
|
|
@@ -259,47 +450,168 @@ class BrowserDaemon {
|
|
|
259
450
|
: await context.newPage();
|
|
260
451
|
|
|
261
452
|
const daemon = await BrowserDaemon.initialize({
|
|
262
|
-
session
|
|
453
|
+
session,
|
|
454
|
+
experiments: args.experiments,
|
|
263
455
|
externallyManaged: true,
|
|
264
456
|
browser,
|
|
265
457
|
context,
|
|
266
458
|
page,
|
|
267
|
-
initialPages:
|
|
268
|
-
|
|
269
|
-
navigateUrl: config.url,
|
|
459
|
+
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
460
|
+
navigateUrl: config.initialUrl,
|
|
270
461
|
});
|
|
271
462
|
|
|
272
463
|
daemon.logger.info("child-connected", {
|
|
273
464
|
cdpEndpoint: config.cdpEndpoint,
|
|
274
|
-
url: config.
|
|
465
|
+
url: config.initialUrl,
|
|
275
466
|
pid: process.pid,
|
|
276
|
-
session
|
|
467
|
+
session,
|
|
277
468
|
});
|
|
278
469
|
|
|
279
470
|
return daemon;
|
|
280
471
|
}
|
|
281
472
|
|
|
473
|
+
static async connectToProvider(args: {
|
|
474
|
+
session: string;
|
|
475
|
+
experiments: Experiments;
|
|
476
|
+
browser: DaemonBrowserProviderConfig;
|
|
477
|
+
}): Promise<BrowserDaemon> {
|
|
478
|
+
const { session, browser: config } = args;
|
|
479
|
+
const provider = getCloudProviderApi(config.providerName);
|
|
480
|
+
const providerSession = await provider.createSession();
|
|
481
|
+
try {
|
|
482
|
+
const browser = await chromium.connectOverCDP(
|
|
483
|
+
providerSession.cdpEndpoint,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const contexts = browser.contexts();
|
|
487
|
+
const context =
|
|
488
|
+
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
489
|
+
const operationalPages = context.pages().filter(isOperationalPage);
|
|
490
|
+
const page =
|
|
491
|
+
operationalPages.length > 0
|
|
492
|
+
? operationalPages[operationalPages.length - 1]
|
|
493
|
+
: await context.newPage();
|
|
494
|
+
|
|
495
|
+
const daemon = await BrowserDaemon.initialize({
|
|
496
|
+
session,
|
|
497
|
+
experiments: args.experiments,
|
|
498
|
+
externallyManaged: true,
|
|
499
|
+
browser,
|
|
500
|
+
context,
|
|
501
|
+
page,
|
|
502
|
+
initialPages: operationalPages.length > 0 ? operationalPages : [page],
|
|
503
|
+
navigateUrl: config.initialUrl,
|
|
504
|
+
readyProvider: {
|
|
505
|
+
name: config.providerName,
|
|
506
|
+
sessionId: providerSession.sessionId,
|
|
507
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
508
|
+
liveViewUrl: providerSession.liveViewUrl,
|
|
509
|
+
},
|
|
510
|
+
providerSession: {
|
|
511
|
+
provider,
|
|
512
|
+
name: config.providerName,
|
|
513
|
+
sessionId: providerSession.sessionId,
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
daemon.logger.info("child-provider-connected", {
|
|
518
|
+
provider: config.providerName,
|
|
519
|
+
sessionId: providerSession.sessionId,
|
|
520
|
+
url: config.initialUrl,
|
|
521
|
+
pid: process.pid,
|
|
522
|
+
session,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
return daemon;
|
|
526
|
+
} catch (error) {
|
|
527
|
+
await provider.closeSession(providerSession.sessionId);
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
282
532
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
283
533
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
534
|
+
registerShutdownHandler(handler: ShutdownHandler): void {
|
|
535
|
+
this.shutdownHandlers.push(handler);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
shutdown(
|
|
539
|
+
reason: string,
|
|
540
|
+
closeBrowser: boolean,
|
|
541
|
+
options: ShutdownOptions = {},
|
|
542
|
+
): Promise<DaemonCloseResult> {
|
|
543
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
544
|
+
this.shutdownPromise = this.performShutdown(reason, closeBrowser, options);
|
|
545
|
+
return this.shutdownPromise;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private async performShutdown(
|
|
549
|
+
reason: string,
|
|
550
|
+
closeBrowser: boolean,
|
|
551
|
+
options: ShutdownOptions,
|
|
552
|
+
): Promise<DaemonCloseResult> {
|
|
553
|
+
let replayUrl: string | undefined;
|
|
554
|
+
try {
|
|
555
|
+
this.logger.info(reason, { session: this.session });
|
|
556
|
+
for (const handler of this.shutdownHandlers) {
|
|
557
|
+
await handler(options);
|
|
558
|
+
}
|
|
559
|
+
if (closeBrowser) {
|
|
560
|
+
if (this.externallyManaged) {
|
|
561
|
+
// Playwright does not expose a public "detach from this CDP browser"
|
|
562
|
+
// API. Closing the private connection lets the daemon's event loop
|
|
563
|
+
// drain without asking Playwright to close the externally managed
|
|
564
|
+
// browser/provider session itself.
|
|
565
|
+
try {
|
|
566
|
+
(
|
|
567
|
+
this.browser as unknown as {
|
|
568
|
+
_connection?: { close(): void };
|
|
569
|
+
}
|
|
570
|
+
)._connection?.close();
|
|
571
|
+
} catch {
|
|
572
|
+
// Connection may already be closed.
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
await this.browser.close();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (this.providerSession) {
|
|
579
|
+
const result = await this.providerSession.provider.closeSession(
|
|
580
|
+
this.providerSession.sessionId,
|
|
581
|
+
);
|
|
582
|
+
replayUrl = result.replayUrl;
|
|
583
|
+
if (result.replayUrl) {
|
|
584
|
+
this.logger.info("provider-recording", {
|
|
585
|
+
session: this.session,
|
|
586
|
+
provider: this.providerSession.name,
|
|
587
|
+
sessionId: this.providerSession.sessionId,
|
|
588
|
+
replayUrl: result.replayUrl,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
writeFileSync(
|
|
592
|
+
getSessionProviderClosePath(this.session),
|
|
593
|
+
JSON.stringify(
|
|
594
|
+
{
|
|
595
|
+
provider: this.providerSession.name,
|
|
596
|
+
sessionId: this.providerSession.sessionId,
|
|
597
|
+
replayUrl: result.replayUrl,
|
|
598
|
+
},
|
|
599
|
+
null,
|
|
600
|
+
2,
|
|
601
|
+
),
|
|
602
|
+
"utf8",
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return replayUrl ? { replayUrl } : {};
|
|
606
|
+
} finally {
|
|
607
|
+
if (options.keepIpcClientsAlive) {
|
|
608
|
+
setImmediate(() => {
|
|
609
|
+
for (const cli of this.connectedClis) {
|
|
610
|
+
cli.destroy();
|
|
296
611
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// Connection may already be closed.
|
|
612
|
+
this.connectedClis.clear();
|
|
613
|
+
});
|
|
300
614
|
}
|
|
301
|
-
} else {
|
|
302
|
-
await this.browser.close();
|
|
303
615
|
}
|
|
304
616
|
}
|
|
305
617
|
|
|
@@ -307,82 +619,281 @@ class BrowserDaemon {
|
|
|
307
619
|
|
|
308
620
|
private resolveTargetPage(pageId?: string): Page {
|
|
309
621
|
if (!pageId) {
|
|
310
|
-
if (this.
|
|
622
|
+
if (this.page.isClosed()) {
|
|
623
|
+
const openPages = Array.from(this.pageById.values());
|
|
624
|
+
if (openPages.length === 1) return openPages[0];
|
|
311
625
|
throw new Error(
|
|
312
|
-
`
|
|
626
|
+
`The primary page for session "${this.session}" is closed. Run "${librettoCommand(`pages --session ${this.session}`)}" to choose a page id.`,
|
|
313
627
|
);
|
|
314
628
|
}
|
|
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
629
|
return this.page;
|
|
321
630
|
}
|
|
322
631
|
const page = this.pageById.get(pageId);
|
|
323
632
|
if (!page) {
|
|
324
633
|
throw new Error(
|
|
325
|
-
`Page "${pageId}" was not found in session "${this.session}". Run "
|
|
634
|
+
`Page "${pageId}" was not found in session "${this.session}". Run "${librettoCommand(`pages --session ${this.session}`)}" to list ids.`,
|
|
326
635
|
);
|
|
327
636
|
}
|
|
328
637
|
return page;
|
|
329
638
|
}
|
|
330
639
|
|
|
331
|
-
// ── IPC
|
|
640
|
+
// ── IPC handlers ───────────────────────────────────────────────────
|
|
641
|
+
|
|
642
|
+
private createIpcHandlers(): IpcPeerHandlers<CliToDaemonApi> {
|
|
643
|
+
return {
|
|
644
|
+
ping: () => ({ protocolVersion: PROTOCOL_VERSION }),
|
|
645
|
+
pages: () =>
|
|
646
|
+
this.withRequestTimeout(() => handlePages(this.pageById, this.page)),
|
|
647
|
+
exec: (args) => this.runExec(args),
|
|
648
|
+
readonlyExec: (args) => this.runReadonlyExec(args),
|
|
649
|
+
snapshot: (args) => this.runSnapshot(args),
|
|
650
|
+
getWorkflowStatus: () => this.getWorkflowStatus(),
|
|
651
|
+
resumeWorkflow: () => this.resumeWorkflow(),
|
|
652
|
+
close: () =>
|
|
653
|
+
this.shutdown("ipc-close", true, {
|
|
654
|
+
keepIpcClientsAlive: true,
|
|
655
|
+
}),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
332
658
|
|
|
333
|
-
private async
|
|
334
|
-
|
|
335
|
-
|
|
659
|
+
private async runSnapshot(
|
|
660
|
+
args: Parameters<CliToDaemonApi["snapshot"]>[0],
|
|
661
|
+
): Promise<ReturnType<CliToDaemonApi["snapshot"]>> {
|
|
662
|
+
if (args.mode === "compact") {
|
|
663
|
+
if (!this.experiments["compact-snapshot-format"]) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`The compact-snapshot-format experiment is not enabled for session "${this.session}". ` +
|
|
666
|
+
`Close and reopen the session after running ${librettoCommand("experiments enable compact-snapshot-format")}.`,
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
const targetPage = this.resolveTargetPage(args.pageId);
|
|
670
|
+
const result = await this.withRequestTimeout(() =>
|
|
671
|
+
handleCompactSnapshot(
|
|
672
|
+
targetPage,
|
|
673
|
+
this.session,
|
|
674
|
+
this.logger,
|
|
675
|
+
{
|
|
676
|
+
pageId: args.pageId,
|
|
677
|
+
cachedSnapshot: this.latestCompactSnapshotByPage.get(targetPage),
|
|
678
|
+
useCachedSnapshot: args.useCachedSnapshot,
|
|
679
|
+
},
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
if (!args.useCachedSnapshot) {
|
|
683
|
+
this.latestCompactSnapshotByPage.set(targetPage, result.snapshot);
|
|
684
|
+
}
|
|
685
|
+
return result;
|
|
336
686
|
}
|
|
337
687
|
|
|
688
|
+
return this.withRequestTimeout(() =>
|
|
689
|
+
handleSnapshot(
|
|
690
|
+
this.resolveTargetPage(args.pageId),
|
|
691
|
+
this.session,
|
|
692
|
+
this.logger,
|
|
693
|
+
args.pageId,
|
|
694
|
+
),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async withRequestTimeout<T>(
|
|
699
|
+
operation: () => Promise<T> | T,
|
|
700
|
+
): Promise<T> {
|
|
338
701
|
// All non-ping commands get a timeout guard. The timer is cleared
|
|
339
702
|
// when the command settles to avoid orphaned timers that would
|
|
340
703
|
// 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
|
-
|
|
704
|
+
let timerId: ReturnType<typeof setTimeout> | undefined;
|
|
705
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
706
|
+
timerId = setTimeout(
|
|
707
|
+
() =>
|
|
708
|
+
reject(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)),
|
|
709
|
+
REQUEST_TIMEOUT_MS,
|
|
710
|
+
);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
return await Promise.race([operation(), timeout]);
|
|
715
|
+
} finally {
|
|
716
|
+
if (timerId) clearTimeout(timerId);
|
|
717
|
+
}
|
|
354
718
|
}
|
|
355
719
|
|
|
356
|
-
private async
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
720
|
+
private async runExec(
|
|
721
|
+
args: Parameters<CliToDaemonApi["exec"]>[0],
|
|
722
|
+
): Promise<DaemonExecResult> {
|
|
723
|
+
if (this.experiments["compact-snapshot-format"]) {
|
|
724
|
+
return this.runCompactExec(args);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const data = await this.withRequestTimeout(() =>
|
|
729
|
+
handleExec(
|
|
730
|
+
this.resolveTargetPage(args.pageId),
|
|
731
|
+
args.code,
|
|
364
732
|
this.context,
|
|
365
733
|
this.browser,
|
|
366
734
|
this.execState,
|
|
367
735
|
this.session,
|
|
368
|
-
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
736
|
+
args.visualize,
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
return { ok: true, data };
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return this.createExecErrorResult(error);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private async runCompactExec(
|
|
746
|
+
args: Parameters<CliToDaemonApi["exec"]>[0],
|
|
747
|
+
): Promise<DaemonExecResult> {
|
|
748
|
+
let targetPage: Page | undefined;
|
|
749
|
+
try {
|
|
750
|
+
targetPage = this.resolveTargetPage(args.pageId);
|
|
751
|
+
const page = targetPage;
|
|
752
|
+
const data = await this.withRequestTimeout(async () => {
|
|
753
|
+
const before =
|
|
754
|
+
this.latestCompactSnapshotByPage.get(page) ?? (await snapshot(page));
|
|
755
|
+
const result = await handleExec(
|
|
756
|
+
page,
|
|
757
|
+
args.code,
|
|
758
|
+
this.context,
|
|
759
|
+
this.browser,
|
|
760
|
+
this.execState,
|
|
378
761
|
this.session,
|
|
379
|
-
|
|
380
|
-
request.pageId,
|
|
762
|
+
args.visualize,
|
|
381
763
|
);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
const waitResult = await waitForPageStable(page);
|
|
767
|
+
if (!waitResult.ok) {
|
|
768
|
+
this.logger.warn("compact-exec-stability-wait-incomplete", {
|
|
769
|
+
session: this.session,
|
|
770
|
+
pageId: args.pageId,
|
|
771
|
+
diagnostics: waitResult.diagnostics,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const after = await snapshot(page);
|
|
776
|
+
const snapshotDiff = diffSnapshots(before, after);
|
|
777
|
+
this.latestCompactSnapshotByPage.set(page, after);
|
|
778
|
+
return { ...result, snapshotDiff };
|
|
779
|
+
} catch (error) {
|
|
780
|
+
this.latestCompactSnapshotByPage.delete(page);
|
|
781
|
+
this.logger.warn("compact-exec-diff-failed", {
|
|
782
|
+
session: this.session,
|
|
783
|
+
pageId: args.pageId,
|
|
784
|
+
error: error instanceof Error ? error.message : String(error),
|
|
785
|
+
});
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return { ok: true, data };
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (targetPage) this.latestCompactSnapshotByPage.delete(targetPage);
|
|
792
|
+
return this.createExecErrorResult(error);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private async runReadonlyExec(
|
|
797
|
+
args: Parameters<CliToDaemonApi["readonlyExec"]>[0],
|
|
798
|
+
): Promise<DaemonExecResult> {
|
|
799
|
+
try {
|
|
800
|
+
const data = await this.withRequestTimeout(() =>
|
|
801
|
+
handleReadonlyExec(this.resolveTargetPage(args.pageId), args.code),
|
|
802
|
+
);
|
|
803
|
+
return { ok: true, data };
|
|
804
|
+
} catch (error) {
|
|
805
|
+
return this.createExecErrorResult(error);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private createExecErrorResult(error: unknown): DaemonExecResult {
|
|
810
|
+
return {
|
|
811
|
+
ok: false,
|
|
812
|
+
message: error instanceof Error ? error.message : String(error),
|
|
813
|
+
output:
|
|
814
|
+
error instanceof Error ? (error as ErrorWithOutput).output : undefined,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
startWorkflow(args: {
|
|
819
|
+
workflow: DaemonWorkflowConfig;
|
|
820
|
+
headed: boolean;
|
|
821
|
+
loadedWorkflow?: ExportedLibrettoWorkflow;
|
|
822
|
+
}): void {
|
|
823
|
+
if (this.workflowController) {
|
|
824
|
+
throw new Error("Workflow controller has already started.");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
this.workflowController = new WorkflowController({
|
|
828
|
+
session: this.session,
|
|
829
|
+
headed: args.headed,
|
|
830
|
+
page: this.page,
|
|
831
|
+
context: this.context,
|
|
832
|
+
logger: this.logger,
|
|
833
|
+
onLog: (event) => {
|
|
834
|
+
this.broadcast("workflowOutput", event);
|
|
835
|
+
},
|
|
836
|
+
onOutcome: (outcome) => {
|
|
837
|
+
if (outcome.state === "paused") {
|
|
838
|
+
this.broadcast("workflowPaused", {
|
|
839
|
+
pausedAt: outcome.pausedAt,
|
|
840
|
+
url: outcome.url,
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
this.broadcast(
|
|
845
|
+
"workflowFinished",
|
|
846
|
+
outcome.result === "completed"
|
|
847
|
+
? { result: "completed", completedAt: outcome.completedAt }
|
|
848
|
+
: {
|
|
849
|
+
result: "failed",
|
|
850
|
+
message: outcome.message,
|
|
851
|
+
phase: outcome.phase,
|
|
852
|
+
},
|
|
385
853
|
);
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
this.workflowController.start({
|
|
857
|
+
integrationPath: args.workflow.integrationPath,
|
|
858
|
+
params: args.workflow.params,
|
|
859
|
+
visualize: args.workflow.visualize,
|
|
860
|
+
loadedWorkflow: args.loadedWorkflow,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
getWorkflowStatus(): ReturnType<WorkflowController["getStatus"]> {
|
|
865
|
+
return this.workflowController?.getStatus() ?? { state: "idle" };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
resumeWorkflow(): void {
|
|
869
|
+
if (!this.workflowController) {
|
|
870
|
+
throw new Error("Workflow is not paused.");
|
|
871
|
+
}
|
|
872
|
+
this.workflowController.resume();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async broadcast<Name extends keyof DaemonToCliApi>(
|
|
876
|
+
name: Name,
|
|
877
|
+
message: Parameters<DaemonToCliApi[Name]>[0],
|
|
878
|
+
): Promise<void> {
|
|
879
|
+
const results = await Promise.allSettled(
|
|
880
|
+
Array.from(this.connectedClis, (cli) => {
|
|
881
|
+
const call = cli.call[name] as (
|
|
882
|
+
message: Parameters<DaemonToCliApi[Name]>[0],
|
|
883
|
+
) => Promise<void>;
|
|
884
|
+
return call(message);
|
|
885
|
+
}),
|
|
886
|
+
);
|
|
887
|
+
for (const result of results) {
|
|
888
|
+
if (result.status === "rejected") {
|
|
889
|
+
this.logger.warn("workflow-event-failed", {
|
|
890
|
+
event: name,
|
|
891
|
+
error:
|
|
892
|
+
result.reason instanceof Error
|
|
893
|
+
? result.reason.message
|
|
894
|
+
: String(result.reason),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
386
897
|
}
|
|
387
898
|
}
|
|
388
899
|
}
|
|
@@ -391,10 +902,65 @@ class BrowserDaemon {
|
|
|
391
902
|
|
|
392
903
|
async function main(): Promise<void> {
|
|
393
904
|
const config = JSON.parse(process.argv[2]) as DaemonConfig;
|
|
905
|
+
const headed =
|
|
906
|
+
config.browser.kind === "launch" ? config.browser.headed : false;
|
|
394
907
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
908
|
+
let loadedWorkflow: ExportedLibrettoWorkflow | undefined;
|
|
909
|
+
if (config.workflow) {
|
|
910
|
+
try {
|
|
911
|
+
loadedWorkflow = await loadDefaultWorkflow(
|
|
912
|
+
getAbsoluteIntegrationPath(config.workflow.integrationPath),
|
|
913
|
+
);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
throw new UserFacingStartupError(
|
|
916
|
+
error instanceof Error ? error.message : String(error),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const daemon =
|
|
922
|
+
config.browser.kind === "provider"
|
|
923
|
+
? await BrowserDaemon.connectToProvider({
|
|
924
|
+
session: config.session,
|
|
925
|
+
experiments: config.experiments,
|
|
926
|
+
browser: config.browser,
|
|
927
|
+
})
|
|
928
|
+
: config.browser.kind === "connect"
|
|
929
|
+
? await BrowserDaemon.connectToEndpoint({
|
|
930
|
+
session: config.session,
|
|
931
|
+
experiments: config.experiments,
|
|
932
|
+
browser: config.browser,
|
|
933
|
+
})
|
|
934
|
+
: await BrowserDaemon.launchBrowser({
|
|
935
|
+
session: config.session,
|
|
936
|
+
experiments: config.experiments,
|
|
937
|
+
browser: config.browser,
|
|
938
|
+
workflow: config.workflow,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
if (config.workflow) {
|
|
942
|
+
void waitForSessionState(config.session)
|
|
943
|
+
.then(() =>
|
|
944
|
+
daemon.startWorkflow({
|
|
945
|
+
workflow: config.workflow!,
|
|
946
|
+
headed,
|
|
947
|
+
loadedWorkflow,
|
|
948
|
+
}),
|
|
949
|
+
)
|
|
950
|
+
.catch((error) => {
|
|
951
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
952
|
+
daemon.logger.error("workflow-failed", {
|
|
953
|
+
error: message,
|
|
954
|
+
});
|
|
955
|
+
return daemon
|
|
956
|
+
.broadcast("workflowFinished", {
|
|
957
|
+
result: "failed" as const,
|
|
958
|
+
message,
|
|
959
|
+
phase: "setup" as const,
|
|
960
|
+
})
|
|
961
|
+
.finally(() => daemon.shutdown("workflow-start-failed", true));
|
|
962
|
+
});
|
|
963
|
+
}
|
|
398
964
|
|
|
399
965
|
process.on("SIGTERM", () => {
|
|
400
966
|
void daemon.shutdown("child-sigterm", true);
|
|
@@ -426,4 +992,18 @@ async function main(): Promise<void> {
|
|
|
426
992
|
// letting the process exit naturally.
|
|
427
993
|
}
|
|
428
994
|
|
|
429
|
-
|
|
995
|
+
function reportStartupError(error: unknown): never {
|
|
996
|
+
if (error instanceof UserFacingStartupError) {
|
|
997
|
+
process.send?.({
|
|
998
|
+
type: "startup-error",
|
|
999
|
+
message: error.message,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
process.exit(1);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
await main();
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
reportStartupError(error);
|
|
1009
|
+
}
|