libretto 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +4 -0
  2. package/README.template.md +4 -0
  3. package/dist/cli/cli.js +4 -3
  4. package/dist/cli/commands/ai.js +3 -2
  5. package/dist/cli/commands/browser.js +17 -17
  6. package/dist/cli/commands/execution.js +254 -234
  7. package/dist/cli/commands/experiments.js +100 -0
  8. package/dist/cli/commands/setup.js +20 -34
  9. package/dist/cli/commands/shared.js +10 -0
  10. package/dist/cli/commands/snapshot.js +81 -9
  11. package/dist/cli/commands/status.js +5 -4
  12. package/dist/cli/core/ai-model.js +6 -3
  13. package/dist/cli/core/browser.js +300 -121
  14. package/dist/cli/core/config.js +4 -2
  15. package/dist/cli/core/context.js +4 -0
  16. package/dist/cli/core/daemon/config.js +0 -6
  17. package/dist/cli/core/daemon/daemon.js +535 -89
  18. package/dist/cli/core/daemon/ipc.js +170 -129
  19. package/dist/cli/core/daemon/snapshot.js +72 -6
  20. package/dist/cli/core/experiments.js +66 -0
  21. package/dist/cli/core/session.js +5 -4
  22. package/dist/cli/core/skill-version.js +2 -1
  23. package/dist/cli/core/snapshot-analyzer.js +4 -3
  24. package/dist/cli/core/workflow-runner/runner.js +147 -0
  25. package/dist/cli/core/workflow-runtime.js +60 -0
  26. package/dist/cli/router.js +4 -1
  27. package/dist/shared/debug/pause-handler.d.ts +9 -0
  28. package/dist/shared/debug/pause-handler.js +15 -0
  29. package/dist/shared/debug/pause.d.ts +1 -2
  30. package/dist/shared/debug/pause.js +13 -36
  31. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  32. package/dist/shared/ipc/child-process-transport.js +60 -0
  33. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  34. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  35. package/dist/shared/ipc/ipc.d.ts +46 -0
  36. package/dist/shared/ipc/ipc.js +165 -0
  37. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  38. package/dist/shared/ipc/ipc.spec.js +114 -0
  39. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  40. package/dist/shared/ipc/socket-transport.js +143 -0
  41. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  42. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  43. package/dist/shared/package-manager.d.ts +7 -0
  44. package/dist/shared/package-manager.js +60 -0
  45. package/dist/shared/paths/paths.d.ts +1 -8
  46. package/dist/shared/paths/paths.js +1 -49
  47. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  48. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  49. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  50. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  51. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  52. package/dist/shared/snapshot/render-snapshot.js +651 -0
  53. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  54. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  55. package/dist/shared/snapshot/types.d.ts +40 -0
  56. package/dist/shared/snapshot/types.js +0 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  58. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  59. package/dist/shared/state/session-state.d.ts +1 -0
  60. package/dist/shared/state/session-state.js +1 -0
  61. package/docs/experiments.md +67 -0
  62. package/package.json +4 -2
  63. package/skills/libretto/SKILL.md +3 -1
  64. package/skills/libretto-readonly/SKILL.md +1 -1
  65. package/src/cli/AGENTS.md +7 -0
  66. package/src/cli/cli.ts +4 -3
  67. package/src/cli/commands/ai.ts +3 -2
  68. package/src/cli/commands/browser.ts +13 -11
  69. package/src/cli/commands/execution.ts +303 -271
  70. package/src/cli/commands/experiments.ts +120 -0
  71. package/src/cli/commands/setup.ts +18 -36
  72. package/src/cli/commands/shared.ts +20 -0
  73. package/src/cli/commands/snapshot.ts +99 -11
  74. package/src/cli/commands/status.ts +5 -4
  75. package/src/cli/core/ai-model.ts +6 -3
  76. package/src/cli/core/browser.ts +369 -147
  77. package/src/cli/core/config.ts +3 -1
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +686 -106
  81. package/src/cli/core/daemon/ipc.ts +330 -214
  82. package/src/cli/core/daemon/snapshot.ts +106 -8
  83. package/src/cli/core/experiments.ts +85 -0
  84. package/src/cli/core/session.ts +5 -4
  85. package/src/cli/core/skill-version.ts +2 -1
  86. package/src/cli/core/snapshot-analyzer.ts +4 -3
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +85 -0
  89. package/src/cli/router.ts +4 -1
  90. package/src/shared/debug/pause-handler.ts +20 -0
  91. package/src/shared/debug/pause.ts +14 -48
  92. package/src/shared/ipc/AGENTS.md +24 -0
  93. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  94. package/src/shared/ipc/child-process-transport.ts +96 -0
  95. package/src/shared/ipc/ipc.spec.ts +161 -0
  96. package/src/shared/ipc/ipc.ts +288 -0
  97. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  98. package/src/shared/ipc/socket-transport.ts +189 -0
  99. package/src/shared/package-manager.ts +76 -0
  100. package/src/shared/paths/paths.ts +0 -72
  101. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  102. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  103. package/src/shared/snapshot/render-snapshot.ts +962 -0
  104. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  105. package/src/shared/snapshot/types.ts +43 -0
  106. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  107. package/src/shared/state/session-state.ts +1 -0
  108. package/dist/cli/core/daemon/index.js +0 -16
  109. package/dist/cli/core/daemon/spawn.js +0 -90
  110. package/dist/cli/core/pause-signals.js +0 -29
  111. package/dist/cli/workers/run-integration-runtime.js +0 -235
  112. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  113. package/dist/cli/workers/run-integration-worker.js +0 -64
  114. package/src/cli/core/daemon/index.ts +0 -24
  115. package/src/cli/core/daemon/spawn.ts +0 -171
  116. package/src/cli/core/pause-signals.ts +0 -35
  117. package/src/cli/workers/run-integration-runtime.ts +0 -326
  118. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  119. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -1,235 +0,0 @@
1
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
3
- import { cwd } from "node:process";
4
- import { isAbsolute, resolve } from "node:path";
5
- import { pathToFileURL } from "node:url";
6
- import {
7
- getDefaultWorkflowFromModuleExports,
8
- getWorkflowsFromModuleExports,
9
- instrumentContext,
10
- launchBrowser
11
- } from "../../index.js";
12
- import { parseSessionStateContent } from "../../shared/state/index.js";
13
- import {
14
- getProfilePath,
15
- normalizeDomain,
16
- normalizeUrl
17
- } from "../core/browser.js";
18
- import {
19
- getSessionActionsLogPath,
20
- getSessionNetworkLogPath,
21
- getSessionStatePath
22
- } from "../core/context.js";
23
- import {
24
- getPauseSignalPaths,
25
- removeSignalIfExists
26
- } from "../core/pause-signals.js";
27
- import { installSessionTelemetry } from "../core/session-telemetry.js";
28
- const FAILURE_HOLD_POLL_INTERVAL_MS = 250;
29
- const TSCONFIG_HINT = "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
30
- function isTsxCompileError(error) {
31
- return error instanceof Error && (error.name === "TransformError" || error.message.startsWith("Cannot resolve tsconfig at path:"));
32
- }
33
- function mirrorStdoutToFile(filePath) {
34
- const stdout = process.stdout;
35
- const originalWrite = stdout.write.bind(stdout);
36
- stdout.write = ((chunk, ...args) => {
37
- try {
38
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
39
- appendFileSync(filePath, buffer);
40
- } catch {
41
- }
42
- return originalWrite(chunk, ...args);
43
- });
44
- return () => {
45
- stdout.write = originalWrite;
46
- };
47
- }
48
- function readSessionStatePid(session) {
49
- const statePath = getSessionStatePath(session);
50
- if (!existsSync(statePath)) return null;
51
- try {
52
- return parseSessionStateContent(readFileSync(statePath, "utf8"), statePath).pid ?? null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
- async function waitForFailureSessionRelease(args) {
58
- const { session, expectedPid, logger } = args;
59
- logger.info("run-failure-session-hold", { session, expectedPid });
60
- while (true) {
61
- const currentPid = readSessionStatePid(session);
62
- if (currentPid !== expectedPid) {
63
- logger.info("run-failure-session-released", {
64
- session,
65
- expectedPid,
66
- currentPid
67
- });
68
- return;
69
- }
70
- await new Promise(
71
- (resolveWait) => setTimeout(resolveWait, FAILURE_HOLD_POLL_INTERVAL_MS)
72
- );
73
- }
74
- }
75
- function getMissingLocalAuthProfileError(args) {
76
- return [
77
- `Local auth profile not found for domain "${args.normalizedDomain}".`,
78
- `Expected profile file: ${args.profilePath}`,
79
- "To create it:",
80
- ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
81
- " 2. Log in manually in the browser window.",
82
- ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`
83
- ].join("\n");
84
- }
85
- function getAbsoluteIntegrationPath(integrationPath) {
86
- const absolutePath = isAbsolute(integrationPath) ? integrationPath : resolve(cwd(), integrationPath);
87
- if (!existsSync(absolutePath)) {
88
- throw new Error(`Integration file does not exist: ${absolutePath}`);
89
- }
90
- return absolutePath;
91
- }
92
- async function loadDefaultWorkflow(absolutePath) {
93
- let loadedModule;
94
- try {
95
- loadedModule = await import(pathToFileURL(absolutePath).href);
96
- } catch (error) {
97
- const message = error instanceof Error ? error.message : String(error);
98
- const compileHint = isTsxCompileError(error) ? `
99
- ${TSCONFIG_HINT}` : "";
100
- throw new Error(
101
- `Failed to import integration module at ${absolutePath}: ${message}${compileHint}`
102
- );
103
- }
104
- const defaultWorkflow = getDefaultWorkflowFromModuleExports(loadedModule);
105
- if (defaultWorkflow) {
106
- return defaultWorkflow;
107
- }
108
- const availableWorkflowNames = getWorkflowsFromModuleExports(loadedModule).map(
109
- (candidate) => candidate.name
110
- );
111
- if (availableWorkflowNames.length === 0) {
112
- throw new Error(
113
- `No default-exported workflow found in ${absolutePath}. Export the workflow with \`export default workflow("name", handler)\`.`
114
- );
115
- }
116
- throw new Error(
117
- `No default-exported workflow found in ${absolutePath}. libretto run only uses the file's default export. Available named workflows: ${availableWorkflowNames.join(", ")}`
118
- );
119
- }
120
- async function installHeadedWorkflowVisualization(args) {
121
- await (args.instrument ?? instrumentContext)(args.context, {
122
- visualize: true,
123
- logger: args.logger
124
- });
125
- }
126
- async function runIntegrationInternal(args, options) {
127
- const { logger } = options;
128
- const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
129
- const workflow = await loadDefaultWorkflow(absolutePath);
130
- const signalPaths = getPauseSignalPaths(args.session);
131
- await removeSignalIfExists(signalPaths.pausedSignalPath);
132
- await removeSignalIfExists(signalPaths.resumeSignalPath);
133
- await removeSignalIfExists(signalPaths.completedSignalPath);
134
- await removeSignalIfExists(signalPaths.failedSignalPath);
135
- const restoreStdout = mirrorStdoutToFile(signalPaths.outputSignalPath);
136
- console.log(
137
- `Running workflow "${workflow.name}" from ${absolutePath} (${args.headless ? "headless" : "headed"})...`
138
- );
139
- const integrationLogger = logger.withScope("integration-run", {
140
- integrationPath: absolutePath,
141
- workflowName: workflow.name,
142
- session: args.session
143
- });
144
- const authProfileDomain = args.authProfileDomain;
145
- const normalizedAuthProfileDomain = authProfileDomain ? normalizeDomain(normalizeUrl(authProfileDomain)) : void 0;
146
- const storageStatePath = normalizedAuthProfileDomain ? getProfilePath(normalizedAuthProfileDomain) : void 0;
147
- if (normalizedAuthProfileDomain && storageStatePath && !existsSync(storageStatePath)) {
148
- throw new Error(
149
- getMissingLocalAuthProfileError({
150
- normalizedDomain: normalizedAuthProfileDomain,
151
- profilePath: storageStatePath,
152
- session: args.session
153
- })
154
- );
155
- }
156
- const browserSession = await launchBrowser({
157
- sessionName: args.session,
158
- headless: args.headless,
159
- storageStatePath,
160
- viewport: args.viewport,
161
- accessMode: args.accessMode,
162
- cdpEndpoint: args.cdpEndpoint,
163
- provider: args.provider
164
- });
165
- if (!args.headless && args.visualize !== false) {
166
- await installHeadedWorkflowVisualization({
167
- context: browserSession.context,
168
- logger: integrationLogger
169
- });
170
- }
171
- const actionsLogPath = getSessionActionsLogPath(args.session);
172
- const networkLogPath = getSessionNetworkLogPath(args.session);
173
- await installSessionTelemetry({
174
- context: browserSession.context,
175
- initialPage: browserSession.page,
176
- includeUserDomActions: true,
177
- logAction: (entry) => {
178
- appendFileSync(actionsLogPath, JSON.stringify(entry) + "\n");
179
- },
180
- logNetwork: (entry) => {
181
- appendFileSync(networkLogPath, JSON.stringify(entry) + "\n");
182
- }
183
- });
184
- await browserSession.context.addInitScript(() => {
185
- globalThis.__name = (target, value) => Object.defineProperty(target, "name", { value, configurable: true });
186
- });
187
- const workflowContext = {
188
- session: args.session,
189
- page: browserSession.page
190
- };
191
- try {
192
- try {
193
- await workflow.run(workflowContext, args.params ?? {});
194
- } catch (error) {
195
- const errorMessage = error instanceof Error ? error.message : String(error);
196
- await writeFile(
197
- signalPaths.failedSignalPath,
198
- JSON.stringify(
199
- {
200
- failedAt: (/* @__PURE__ */ new Date()).toISOString(),
201
- message: errorMessage,
202
- phase: "workflow"
203
- },
204
- null,
205
- 2
206
- ),
207
- "utf8"
208
- );
209
- await waitForFailureSessionRelease({
210
- session: args.session,
211
- expectedPid: process.pid,
212
- logger
213
- });
214
- return { status: "failed-held" };
215
- }
216
- await writeFile(
217
- signalPaths.completedSignalPath,
218
- JSON.stringify({ completedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
219
- "utf8"
220
- );
221
- return { status: "completed" };
222
- } finally {
223
- restoreStdout();
224
- await browserSession.close();
225
- }
226
- }
227
- async function runIntegrationFromFileInWorker(args, logger) {
228
- return await runIntegrationInternal(args, {
229
- logger
230
- });
231
- }
232
- export {
233
- installHeadedWorkflowVisualization,
234
- runIntegrationFromFileInWorker
235
- };
@@ -1,17 +0,0 @@
1
- import { z } from "zod";
2
- import { SessionAccessModeSchema } from "../../shared/state/index.js";
3
- const RunIntegrationWorkerRequestSchema = z.object({
4
- integrationPath: z.string().min(1),
5
- session: z.string().min(1),
6
- params: z.unknown(),
7
- headless: z.boolean(),
8
- visualize: z.boolean().default(true),
9
- authProfileDomain: z.string().optional(),
10
- viewport: z.object({ width: z.number(), height: z.number() }).optional(),
11
- accessMode: SessionAccessModeSchema.default("write-access"),
12
- cdpEndpoint: z.string().optional(),
13
- provider: z.object({ name: z.string(), sessionId: z.string() }).optional()
14
- });
15
- export {
16
- RunIntegrationWorkerRequestSchema
17
- };
@@ -1,64 +0,0 @@
1
- import { writeFile } from "node:fs/promises";
2
- import { ZodError } from "zod";
3
- import {
4
- RunIntegrationWorkerRequestSchema
5
- } from "./run-integration-worker-protocol.js";
6
- import { runIntegrationFromFileInWorker } from "./run-integration-runtime.js";
7
- import { ensureLibrettoSetup, withSessionLogger } from "../core/context.js";
8
- import { getPauseSignalPaths } from "../core/pause-signals.js";
9
- function parseWorkerRequest(argv) {
10
- const rawPayload = argv[2];
11
- if (!rawPayload) {
12
- throw new Error("Missing worker payload argument.");
13
- }
14
- let parsed;
15
- try {
16
- parsed = JSON.parse(rawPayload);
17
- } catch (error) {
18
- throw new Error(
19
- `Invalid worker payload JSON: ${error instanceof Error ? error.message : String(error)}`
20
- );
21
- }
22
- try {
23
- return RunIntegrationWorkerRequestSchema.parse(parsed);
24
- } catch (error) {
25
- if (error instanceof ZodError) {
26
- const details = error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
27
- throw new Error(`Worker payload is invalid: ${details}`);
28
- }
29
- throw error;
30
- }
31
- }
32
- async function main() {
33
- let request = null;
34
- let exitCode = 0;
35
- try {
36
- request = parseWorkerRequest(process.argv);
37
- const workerRequest = request;
38
- ensureLibrettoSetup();
39
- await withSessionLogger(workerRequest.session, async (logger) => {
40
- await runIntegrationFromFileInWorker(workerRequest, logger);
41
- });
42
- } catch (error) {
43
- const message = error instanceof Error ? error.message : String(error);
44
- if (request) {
45
- const { failedSignalPath } = getPauseSignalPaths(request.session);
46
- await writeFile(
47
- failedSignalPath,
48
- JSON.stringify(
49
- {
50
- failedAt: (/* @__PURE__ */ new Date()).toISOString(),
51
- message,
52
- phase: "setup"
53
- },
54
- null,
55
- 2
56
- ),
57
- "utf8"
58
- );
59
- }
60
- exitCode = 1;
61
- }
62
- process.exit(exitCode);
63
- }
64
- void main();
@@ -1,24 +0,0 @@
1
- export {
2
- DaemonServer,
3
- DaemonClient,
4
- DaemonClientError,
5
- getDaemonSocketPath,
6
- type DaemonCommandResult,
7
- type DaemonExecOutput,
8
- type DaemonRequest,
9
- type DaemonResponse,
10
- type DaemonResultMap,
11
- type RequestHandler,
12
- } from "./ipc.js";
13
-
14
- export {
15
- type DaemonLaunchConfig,
16
- type DaemonConnectConfig,
17
- type DaemonConfig,
18
- } from "./config.js";
19
-
20
- export {
21
- spawnSessionDaemon,
22
- type SpawnSessionDaemonOptions,
23
- type SpawnSessionDaemonResult,
24
- } from "./spawn.js";
@@ -1,171 +0,0 @@
1
- /**
2
- * Spawn and wait for a browser daemon process.
3
- *
4
- * Shared by `runOpen`, `runConnect`, and `runOpenWithProvider` in
5
- * `browser.ts`. Encapsulates the child-process lifecycle and IPC
6
- * readiness polling so callers only need to provide config and
7
- * handle session-state persistence.
8
- */
9
-
10
- import { openSync, closeSync } from "node:fs";
11
- import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { createRequire } from "node:module";
13
- import { spawn } from "node:child_process";
14
- import type { LoggerApi } from "../../../shared/logger/index.js";
15
- import { getDaemonSocketPath } from "./ipc.js";
16
- import { DaemonClient } from "./ipc.js";
17
- import type { DaemonConfig } from "./config.js";
18
-
19
- // ── Public types ─────────────────────────────────────────────────────
20
-
21
- export type SpawnSessionDaemonOptions = {
22
- /** Daemon config — serialized as JSON and passed to the child process. */
23
- config: DaemonConfig;
24
- session: string;
25
- logger: LoggerApi;
26
- /** Path for the child's stderr log file. */
27
- logPath: string;
28
- /** How long to wait for the daemon's IPC server (default: 10 000 ms). */
29
- ipcTimeoutMs?: number;
30
- /**
31
- * Called before throwing when the daemon fails to start (spawn error,
32
- * early exit, or IPC timeout). Use for cleanup — e.g. closing a cloud
33
- * provider session. Return value is ignored.
34
- */
35
- onFailure?: () => Promise<unknown>;
36
- };
37
-
38
- export type SpawnSessionDaemonResult = {
39
- /** PID of the detached daemon child process. */
40
- pid: number;
41
- /** Unix domain socket path for daemon IPC. */
42
- socketPath: string;
43
- /** Ready-to-use IPC client (already confirmed reachable via ping). */
44
- client: DaemonClient;
45
- };
46
-
47
- // ── Implementation ───────────────────────────────────────────────────
48
-
49
- const DEFAULT_IPC_TIMEOUT_MS = 10_000;
50
- const IPC_POLL_INTERVAL_MS = 250;
51
-
52
- /**
53
- * Spawn a daemon child process with the given config and wait for its
54
- * IPC server to become reachable.
55
- *
56
- * The daemon entry point is resolved relative to this module so the
57
- * caller doesn't need to know where the daemon script lives.
58
- */
59
- export async function spawnSessionDaemon(
60
- options: SpawnSessionDaemonOptions,
61
- ): Promise<SpawnSessionDaemonResult> {
62
- const {
63
- config,
64
- session,
65
- logger,
66
- logPath,
67
- ipcTimeoutMs = DEFAULT_IPC_TIMEOUT_MS,
68
- onFailure,
69
- } = options;
70
-
71
- // Resolve paths for the daemon entry point and tsx loader.
72
- const daemonEntryPath = fileURLToPath(
73
- new URL("./daemon.js", import.meta.url),
74
- );
75
- const require = createRequire(import.meta.url);
76
- const tsxImportPath = pathToFileURL(require.resolve("tsx/esm")).href;
77
-
78
- // Spawn detached child process with stderr going to the log file.
79
- const childStderrFd = openSync(logPath, "a");
80
- const child = spawn(
81
- process.execPath,
82
- ["--import", tsxImportPath, daemonEntryPath, JSON.stringify(config)],
83
- {
84
- detached: true,
85
- stdio: ["ignore", "ignore", childStderrFd],
86
- },
87
- );
88
- child.unref();
89
- closeSync(childStderrFd);
90
-
91
- const pid = child.pid!;
92
- logger.info("daemon-spawned", { pid, session });
93
-
94
- // Track spawn errors and early exits so the polling loop can fail fast.
95
- let childSpawnError: Error | null = null;
96
- let childEarlyExit: {
97
- code: number | null;
98
- signal: NodeJS.Signals | null;
99
- } | null = null;
100
-
101
- child.on("error", (err) => {
102
- childSpawnError = err;
103
- logger.error("daemon-spawn-error", { error: err, session });
104
- });
105
-
106
- child.on("exit", (code, signal) => {
107
- childEarlyExit = { code, signal };
108
- logger.warn("daemon-early-exit", { code, signal, session, pid });
109
- });
110
-
111
- // Poll the daemon's IPC server until it responds to a ping.
112
- const socketPath = getDaemonSocketPath(session);
113
- const client = new DaemonClient(socketPath);
114
- const maxAttempts = Math.ceil(ipcTimeoutMs / IPC_POLL_INTERVAL_MS);
115
- let ipcReady = false;
116
-
117
- for (let i = 0; i < maxAttempts; i++) {
118
- // Fail fast on spawn errors. The cast is needed because TypeScript
119
- // doesn't track that the variable is mutated asynchronously by the
120
- // child's "error" event handler.
121
- const spawnError = childSpawnError as Error | null;
122
- if (spawnError !== null) {
123
- await onFailure?.();
124
- const errWithCode = spawnError as Error & { code?: string };
125
- const hint =
126
- errWithCode.code === "ENOENT"
127
- ? " Ensure Node.js is available in PATH for child processes."
128
- : "";
129
- throw new Error(
130
- `Failed to spawn daemon: ${spawnError.message}.${hint} Check logs: ${logPath}`,
131
- );
132
- }
133
-
134
- // Fail fast on early exit.
135
- const earlyExit = childEarlyExit as {
136
- code: number | null;
137
- signal: NodeJS.Signals | null;
138
- } | null;
139
- if (earlyExit !== null) {
140
- await onFailure?.();
141
- const status = earlyExit.code ?? earlyExit.signal ?? "unknown";
142
- throw new Error(
143
- `Daemon exited before startup (status: ${status}). Check logs: ${logPath}`,
144
- );
145
- }
146
-
147
- await new Promise((r) => setTimeout(r, IPC_POLL_INTERVAL_MS));
148
- ipcReady = await client.ping();
149
- if (ipcReady) break;
150
-
151
- if (i > 0 && i % 10 === 0) {
152
- logger.info("daemon-waiting-for-ipc", { attempt: i, session });
153
- }
154
- }
155
-
156
- if (!ipcReady) {
157
- // Kill the orphaned daemon process before reporting failure.
158
- try {
159
- process.kill(pid, "SIGTERM");
160
- } catch {
161
- // Process may have already exited.
162
- }
163
- await onFailure?.();
164
- throw new Error(
165
- `Daemon failed to start within ${Math.ceil(ipcTimeoutMs / 1000)}s. Check logs: ${logPath}`,
166
- );
167
- }
168
-
169
- logger.info("daemon-ipc-ready", { session, socketPath });
170
- return { pid, socketPath, client };
171
- }
@@ -1,35 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { unlink } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { getSessionDir } from "./context.js";
5
-
6
- export type PauseSignalPaths = {
7
- pausedSignalPath: string;
8
- resumeSignalPath: string;
9
- completedSignalPath: string;
10
- failedSignalPath: string;
11
- outputSignalPath: string;
12
- };
13
-
14
- export function getPauseSignalPaths(session: string): PauseSignalPaths {
15
- const sessionDir = getSessionDir(session);
16
- return {
17
- pausedSignalPath: join(sessionDir, `${session}.paused`),
18
- resumeSignalPath: join(sessionDir, `${session}.resume`),
19
- completedSignalPath: join(sessionDir, `${session}.completed`),
20
- failedSignalPath: join(sessionDir, `${session}.failed`),
21
- outputSignalPath: join(sessionDir, `${session}.output`),
22
- };
23
- }
24
-
25
- export async function removeSignalIfExists(path: string): Promise<void> {
26
- if (!existsSync(path)) return;
27
- try {
28
- await unlink(path);
29
- } catch (error) {
30
- const code = (error as NodeJS.ErrnoException).code;
31
- if (code !== "ENOENT") {
32
- throw error;
33
- }
34
- }
35
- }