@relayflows/sdk 2.0.0 → 2.0.2

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 (72) hide show
  1. package/dist/cli/daemon-refusal.d.ts +12 -0
  2. package/dist/cli/daemon-refusal.d.ts.map +1 -0
  3. package/dist/cli/daemon-refusal.js +42 -0
  4. package/dist/cli/daemon-refusal.js.map +1 -0
  5. package/dist/cli/direct-run.d.ts +1 -1
  6. package/dist/cli/direct-run.d.ts.map +1 -1
  7. package/dist/cli/direct-run.js +2 -2
  8. package/dist/cli/direct-run.js.map +1 -1
  9. package/dist/cli/run.d.ts +26 -4
  10. package/dist/cli/run.d.ts.map +1 -1
  11. package/dist/cli/run.js +51 -6
  12. package/dist/cli/run.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +39 -8
  15. package/dist/cli.js.map +1 -1
  16. package/dist/compile.d.ts.map +1 -1
  17. package/dist/compile.js +35 -4
  18. package/dist/compile.js.map +1 -1
  19. package/dist/daemon-connection.d.ts +127 -0
  20. package/dist/daemon-connection.d.ts.map +1 -0
  21. package/dist/daemon-connection.js +249 -0
  22. package/dist/daemon-connection.js.map +1 -0
  23. package/dist/daemon-lifecycle.d.ts +29 -0
  24. package/dist/daemon-lifecycle.d.ts.map +1 -0
  25. package/dist/daemon-lifecycle.js +152 -0
  26. package/dist/daemon-lifecycle.js.map +1 -0
  27. package/dist/failure-kinds.d.ts +19 -2
  28. package/dist/failure-kinds.d.ts.map +1 -1
  29. package/dist/failure-kinds.js +23 -1
  30. package/dist/failure-kinds.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/journal-client.d.ts +12 -0
  35. package/dist/journal-client.d.ts.map +1 -1
  36. package/dist/journal-client.js +10 -0
  37. package/dist/journal-client.js.map +1 -1
  38. package/dist/protocol.d.ts +21 -1
  39. package/dist/protocol.d.ts.map +1 -1
  40. package/dist/relayflowd-path.d.ts +39 -0
  41. package/dist/relayflowd-path.d.ts.map +1 -0
  42. package/dist/relayflowd-path.js +162 -0
  43. package/dist/relayflowd-path.js.map +1 -0
  44. package/dist/spec.d.ts +30 -2
  45. package/dist/spec.d.ts.map +1 -1
  46. package/dist/spec.js.map +1 -1
  47. package/dist/step-fields.d.ts +1 -1
  48. package/dist/step-fields.d.ts.map +1 -1
  49. package/dist/step-fields.js +2 -0
  50. package/dist/step-fields.js.map +1 -1
  51. package/dist/validate.d.ts.map +1 -1
  52. package/dist/validate.js +53 -6
  53. package/dist/validate.js.map +1 -1
  54. package/dist/worker.js +6 -1
  55. package/dist/worker.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/cli/daemon-refusal.ts +49 -0
  58. package/src/cli/direct-run.ts +2 -2
  59. package/src/cli/run.ts +62 -9
  60. package/src/cli.ts +42 -15
  61. package/src/compile.ts +37 -4
  62. package/src/daemon-connection.ts +336 -0
  63. package/src/daemon-lifecycle.ts +198 -0
  64. package/src/failure-kinds.ts +25 -1
  65. package/src/index.ts +6 -0
  66. package/src/journal-client.ts +21 -0
  67. package/src/protocol.ts +19 -1
  68. package/src/relayflowd-path.ts +190 -0
  69. package/src/spec.ts +34 -2
  70. package/src/step-fields.ts +2 -0
  71. package/src/validate.ts +50 -6
  72. package/src/worker.ts +7 -1
package/src/cli/run.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { join, resolve } from 'node:path';
2
2
  import { toKernelSpec } from '../compile.js';
3
- import type { RunFailureKind } from '../failure-kinds.js';
3
+ import { ensureDaemon, type EnsureDaemonOptions } from '../daemon-lifecycle.js';
4
+ import { daemonRefusal } from './daemon-refusal.js';
5
+ import type { RunFailureKind, RunWarningKind } from '../failure-kinds.js';
4
6
  import { JournalClient, JournalProtocolError } from '../journal-client.js';
5
7
  import type { PreflightDiagnostic } from '../preflight.js';
6
8
  import type {
@@ -24,8 +26,8 @@ export interface ParkedStep {
24
26
  }
25
27
 
26
28
  export interface RunDiagnostic {
27
- severity: 'refusal' | 'failure' | 'parked';
28
- kind: RunFailureKind | RunCompletionReason;
29
+ severity: 'refusal' | 'failure' | 'parked' | 'warning';
30
+ kind: RunFailureKind | RunWarningKind | RunCompletionReason;
29
31
  message: string;
30
32
  }
31
33
 
@@ -59,6 +61,12 @@ export interface RunProgress {
59
61
  export interface RunLifecycleOptions {
60
62
  signal?: AbortSignal;
61
63
  onWait?: (progress: RunProgress) => void;
64
+ /**
65
+ * Attach-or-spawn policy for the daemon this command needs
66
+ * (kernel/DAEMON-LIFECYCLE.md §3). `{ spawn: false }` is `--no-spawn`:
67
+ * refuse instead of starting one, which is today's exact behavior.
68
+ */
69
+ daemon?: EnsureDaemonOptions;
62
70
  }
63
71
 
64
72
  export async function runFlow(
@@ -80,16 +88,19 @@ async function executeCheckedFlow(
80
88
  options: RunLifecycleOptions,
81
89
  ): Promise<RunExecution> {
82
90
  const socketPath = socketFor(dataDir);
91
+ // Carry the preflight's diagnostics as a RunReport from here on, so the
92
+ // attach step has one accumulator to append to (see `connect`).
93
+ const base = fromCheckReport('run', checked.report);
83
94
  const client = new JournalClient(socketPath);
84
- const connected = await connect(client, 'run', dataDir, checked.report);
95
+ const connected = await connect(client, 'run', dataDir, base, options);
85
96
  if (connected !== undefined) return connected;
86
97
 
87
98
  try {
88
99
  const spec = toKernelSpec(checked.flow!);
89
100
  const outcome = await client.runStart(spec);
90
- return await classifyOutcome(client, 'run', outcome, checked.report, socketPath, options);
101
+ return await classifyOutcome(client, 'run', outcome, base, socketPath, options);
91
102
  } catch (error) {
92
- return protocolFailure('run', checked.report, socketPath, error);
103
+ return protocolFailure('run', base, socketPath, error);
93
104
  } finally {
94
105
  client.close();
95
106
  }
@@ -103,7 +114,7 @@ export async function resumeFlow(
103
114
  const socketPath = socketFor(dataDir);
104
115
  const base = emptyReport('resume');
105
116
  const client = new JournalClient(socketPath);
106
- const connected = await connect(client, 'resume', dataDir, base);
117
+ const connected = await connect(client, 'resume', dataDir, base, options);
107
118
  if (connected !== undefined) return connected;
108
119
 
109
120
  try {
@@ -131,13 +142,53 @@ export async function resumeFlow(
131
142
  }
132
143
  }
133
144
 
145
+ /**
146
+ * Get a live daemon, then open the socket to it.
147
+ *
148
+ * This is the single seam every journal-opening verb shares (`runFlow`,
149
+ * `resumeFlow`, `runDirectFlow`), and it is where attach-or-spawn belongs —
150
+ * *after* the command has compiled, preflighted and validated its input, and
151
+ * immediately before `JournalClient` is used. Hoisting it into `runCli`
152
+ * instead would make a malformed invocation start a daemon as a side effect,
153
+ * breaking the surface's promise that missing, invalid, and oversized input is
154
+ * refused before the CLI contacts relayflowd (docs/SURFACE.md §5).
155
+ *
156
+ * Everything past `ensureDaemon` is unchanged and still fails closed: a
157
+ * connect or `hello` that fails against a daemon we just attached to is a
158
+ * refusal, with no retry and no second spawn.
159
+ */
134
160
  export async function connect(
135
161
  client: JournalClient,
136
162
  command: RunCommand,
137
163
  dataDir: string,
138
- base: CheckReport | RunReport,
164
+ base: RunReport,
165
+ options: RunLifecycleOptions = {},
139
166
  ): Promise<RunExecution | undefined> {
140
167
  const socketPath = socketFor(dataDir);
168
+ const daemon = await ensureDaemon(dataDir, options.daemon ?? {});
169
+ if (daemon.kind !== 'attached') {
170
+ client.close();
171
+ return {
172
+ exitCode: 2,
173
+ report: {
174
+ ...fromBase(command, base),
175
+ socketPath,
176
+ diagnostics: [...base.diagnostics, daemonRefusal(daemon, dataDir, socketPath)],
177
+ },
178
+ };
179
+ }
180
+ if (daemon.warning !== undefined) {
181
+ // `base.diagnostics` is the accumulator that becomes the report's
182
+ // diagnostics, so a warning raised while attaching belongs in it — the
183
+ // attach succeeded, and silence about an anomaly is what AGENTS.md rule 4
184
+ // forbids.
185
+ base.diagnostics.push({
186
+ severity: 'warning',
187
+ kind: 'connection_file_stale',
188
+ message: daemon.warning,
189
+ });
190
+ }
191
+
141
192
  try {
142
193
  await client.connect();
143
194
  } catch {
@@ -396,7 +447,9 @@ export function fromCheckReport(command: RunCommand, report: CheckReport): RunRe
396
447
  ...(report.path !== undefined ? { path: report.path } : {}),
397
448
  ...(report.projectConfigPath !== undefined ? { projectConfigPath: report.projectConfigPath } : {}),
398
449
  resolutions: report.resolutions,
399
- diagnostics: report.diagnostics,
450
+ // Copied, not aliased: the returned report is an accumulator the attach
451
+ // step appends to, and it must not write back into the check report.
452
+ diagnostics: [...report.diagnostics],
400
453
  };
401
454
  }
402
455
 
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  resumeFlow,
12
12
  runFlow,
13
13
  type RunExecution,
14
+ type RunProgress,
14
15
  type RunReport,
15
16
  } from './cli/run.js';
16
17
  import { runDirectFlow } from './cli/direct-run.js';
@@ -28,8 +29,8 @@ export interface CliIo {
28
29
  type CliExitCode = 0 | 1 | 2 | 3;
29
30
  type ParsedArgs =
30
31
  | { command: 'check'; json: boolean; value: string }
31
- | { command: 'run'; dataDir: string; input: string | undefined; json: boolean; value: string }
32
- | { command: 'resume'; dataDir: string; json: boolean; value: string }
32
+ | { command: 'run'; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; value: string }
33
+ | { command: 'resume'; dataDir: string; json: boolean; spawn: boolean; value: string }
33
34
  | { command: 'hn-monitor'; sub: 'start'; dataDir: string; specPath: string; pollIntervalMs: number | undefined }
34
35
  | { command: 'tick'; sub: 'start'; dataDir: string; specPath: string; scheduleId: string;
35
36
  intervalMs: number; epochMs: number | undefined; maxCatchUp: number | undefined;
@@ -39,13 +40,23 @@ const DEFAULT_DATA_DIR = '.relayflowd';
39
40
  const USAGE = [
40
41
  'Usage:',
41
42
  'flows check [--json] <flow.yaml|spec.json>',
42
- 'flows run [--json] [--data-dir <dir>] <flow.yaml|spec.json>',
43
- 'flows run [--json] [--data-dir <dir>] <flow.ts> --input <inline-json-or-file>',
43
+ 'flows run [--json] [--no-spawn] [--data-dir <dir>] <flow.yaml|spec.json>',
44
+ 'flows run [--json] [--no-spawn] [--data-dir <dir>] <flow.ts> --input <inline-json-or-file>',
44
45
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
45
- 'flows resume [--json] [--data-dir <dir>] <run-id>',
46
+ 'flows resume [--json] [--no-spawn] [--data-dir <dir>] <run-id>',
46
47
  'flows hn-monitor start [--data-dir <dir>] [--poll-interval-ms <n>] <spec.json>',
47
48
  ].join(' ');
48
49
 
50
+ /**
51
+ * `FLOWS_NO_SPAWN=1` is `--no-spawn` for a whole environment: the lever for CI
52
+ * that means to assert a daemon is already present rather than conjure one
53
+ * (kernel/DAEMON-LIFECYCLE.md §4). Only the exact string `1` counts — an
54
+ * unset or empty variable must not be read as an opinion.
55
+ */
56
+ function spawnAllowedByEnv(env: NodeJS.ProcessEnv = process.env): boolean {
57
+ return env['FLOWS_NO_SPAWN'] !== '1';
58
+ }
59
+
49
60
  const PROCESS_IO: CliIo = {
50
61
  stdout: (line) => process.stdout.write(`${line}\n`),
51
62
  stderr: (line) => process.stderr.write(`${line}\n`),
@@ -63,6 +74,11 @@ export async function runCli(
63
74
  }
64
75
 
65
76
  if (parsed.command === 'check') {
77
+ // Deliberately daemon-free (kernel/DAEMON-LIFECYCLE.md §4). `checkFlow` is
78
+ // a pure compile-and-preflight that opens no socket, and the parser
79
+ // refuses `--data-dir` on `check`, so there is no data dir to attach to.
80
+ // `flows check` keeps working with no daemon, no relayflowd binary and no
81
+ // data directory at all -- a property worth keeping, not an omission.
66
82
  const checked = checkFlow(parsed.value);
67
83
  emitCheckReport(checked.report, parsed.json, io);
68
84
  return checked.report.ok ? 0 : 2;
@@ -110,16 +126,19 @@ export async function runCli(
110
126
  }
111
127
  }
112
128
 
129
+ // Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
130
+ // single `connect()` seam immediately before journal-client.ts is used --
131
+ // not here. Hoisting it above the dispatch would start a daemon as a side
132
+ // effect of an invocation that is about to be refused for bad input.
133
+ const lifecycle = {
134
+ onWait: (progress: RunProgress) => emitWait(progress, io),
135
+ daemon: { spawn: parsed.spawn && spawnAllowedByEnv() },
136
+ };
113
137
  const execution = parsed.command === 'run'
114
138
  ? isAuthoredFlowPath(parsed.value)
115
- ? await runDirectFlow(
116
- parsed.value,
117
- parsed.input,
118
- parsed.dataDir,
119
- { onWait: (progress) => emitWait(progress, io) },
120
- )
121
- : await runFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) })
122
- : await resumeFlow(parsed.value, parsed.dataDir, { onWait: (progress) => emitWait(progress, io) });
139
+ ? await runDirectFlow(parsed.value, parsed.input, parsed.dataDir, lifecycle)
140
+ : await runFlow(parsed.value, parsed.dataDir, lifecycle)
141
+ : await resumeFlow(parsed.value, parsed.dataDir, lifecycle);
123
142
  emitRunReport(execution, parsed.json, io);
124
143
  return execution.exitCode;
125
144
  }
@@ -143,6 +162,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
143
162
  let json = false;
144
163
  let dataDir = DEFAULT_DATA_DIR;
145
164
  let sawDataDir = false;
165
+ let spawn = true;
146
166
  let input: string | undefined;
147
167
  let sawInput = false;
148
168
  const positionals: string[] = [];
@@ -153,6 +173,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
153
173
  json = true;
154
174
  continue;
155
175
  }
176
+ if (argument === '--no-spawn') {
177
+ // Refused on `check` for the same reason `--data-dir` is: `check` never
178
+ // opens a socket, so a daemon flag there would describe nothing.
179
+ if (command === 'check' || !spawn) return undefined;
180
+ spawn = false;
181
+ continue;
182
+ }
156
183
  if (argument === '--data-dir') {
157
184
  const value = args[index + 1];
158
185
  if (command === 'check' || sawDataDir || value === undefined || value.startsWith('-')) return undefined;
@@ -178,8 +205,8 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
178
205
  return command === 'check'
179
206
  ? { command, json, value: positionals[0]! }
180
207
  : command === 'run'
181
- ? { command, dataDir, input, json, value: positionals[0]! }
182
- : { command, dataDir, json, value: positionals[0]! };
208
+ ? { command, dataDir, input, json, spawn, value: positionals[0]! }
209
+ : { command, dataDir, json, spawn, value: positionals[0]! };
183
210
  }
184
211
 
185
212
  function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
package/src/compile.ts CHANGED
@@ -109,6 +109,8 @@ function compileStep(step: StepSpec): StepSpec {
109
109
  type: step.type,
110
110
  ...(step.dependsOn !== undefined ? { dependsOn: step.dependsOn } : {}),
111
111
  maxIterations,
112
+ ...(step.memory !== undefined ? { memory: step.memory } : {}),
113
+ ...(step.requirements !== undefined ? { requirements: step.requirements } : {}),
112
114
  };
113
115
 
114
116
  switch (step.type as StepType) {
@@ -352,13 +354,13 @@ function kernelTriggerToAuthoring(value: unknown, at: string): unknown {
352
354
 
353
355
  function kernelStepToAuthoring(value: unknown, at: string): unknown {
354
356
  const unionKeys = [
355
- 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification',
357
+ 'id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements',
356
358
  'command', 'timeout_ms', 'prompt', 'model', 'cli', 'instruction',
357
359
  'recovery_mode', 'surfaces', 'permissions',
358
360
  ] as const;
359
361
  const step = requireKernelObject(value, unionKeys, at);
360
362
  const type = step['type'];
361
- const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification'] as const;
363
+ const commonKeys = ['id', 'type', 'depends_on', 'max_iterations', 'retry', 'verification', 'memory', 'requirements'] as const;
362
364
  const typeKeys = type === 'deterministic'
363
365
  ? ['command', 'timeout_ms'] as const
364
366
  : type === 'llm'
@@ -367,16 +369,18 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
367
369
  ? ['instruction', 'cli', 'model', 'recovery_mode', 'surfaces', 'permissions'] as const
368
370
  : [];
369
371
  assertKernelKeys(step, [...commonKeys, ...typeKeys], at);
370
- if (step['retry'] !== undefined) validateKernelRetry(step['retry'], `${at}.retry`);
372
+ if (step['retry'] !== undefined) validateAuthoringRetryDefaults(step['retry'], `${at}.retry`);
371
373
  const dependsOn = step['depends_on'];
372
374
  const common = {
373
375
  id: step['id'],
374
376
  type,
377
+ ...(step['requirements'] !== undefined ? { requirements: kernelRequirementsToAuthoring(step['requirements'], `${at}.requirements`) } : {}),
375
378
  ...(dependsOn !== undefined && (!Array.isArray(dependsOn) || dependsOn.length > 0)
376
379
  ? { dependsOn }
377
380
  : {}),
378
381
  ...(step['max_iterations'] !== undefined ? { maxIterations: step['max_iterations'] } : {}),
379
382
  ...kernelVerificationToAuthoring(type, step['verification'], `${at}.verification`),
383
+ ...(step['memory'] !== undefined ? { memory: kernelMemoryToAuthoring(step['memory'], `${at}.memory`) } : {}),
380
384
  };
381
385
  if (type === 'deterministic') {
382
386
  return {
@@ -402,7 +406,7 @@ function kernelStepToAuthoring(value: unknown, at: string): unknown {
402
406
  return common;
403
407
  }
404
408
 
405
- function validateKernelRetry(value: unknown, at: string): void {
409
+ function validateAuthoringRetryDefaults(value: unknown, at: string): void {
406
410
  const retry = requireKernelObject(value, [
407
411
  'initial_backoff_ms', 'max_backoff_ms', 'multiplier', 'jitter_percent',
408
412
  ], at);
@@ -434,6 +438,14 @@ function kernelVerificationToAuthoring(
434
438
  return type === 'deterministic' ? { verification: { type: 'exit_code' } } : {};
435
439
  }
436
440
 
441
+ function kernelMemoryToAuthoring(value: unknown, at: string): unknown {
442
+ const memory = requireKernelObject(value, ['scope', 'query', 'budget'], at);
443
+ return {
444
+ scope: memory['scope'], query: memory['query'],
445
+ budget: kernelBudgetToAuthoring(memory['budget'], `${at}.budget`),
446
+ };
447
+ }
448
+
437
449
  function kernelBudgetToAuthoring(value: unknown, at: string): unknown {
438
450
  const budget = requireKernelObject(value, ['max_tokens_in', 'max_tokens_out', 'max_dollars'], at);
439
451
  return {
@@ -497,6 +509,19 @@ function toKernelStep(step: StepSpec): KernelStepSpec {
497
509
  max_iterations: step.maxIterations ?? 1,
498
510
  retry: { ...KERNEL_RETRY_DEFAULTS },
499
511
  verification: toKernelVerification(step),
512
+ ...(step.requirements !== undefined ? { requirements: {
513
+ ...Object.fromEntries(Object.entries(step.requirements).filter(([key]) => key !== 'expectedDurationMs')),
514
+ ...(step.requirements.expectedDurationMs !== undefined ? { expected_duration_ms: step.requirements.expectedDurationMs } : {}),
515
+ } } : {}),
516
+ ...(step.memory !== undefined ? { memory: {
517
+ scope: step.memory.scope,
518
+ query: step.memory.query,
519
+ budget: {
520
+ ...(step.memory.budget.maxTokensIn !== undefined ? { max_tokens_in: step.memory.budget.maxTokensIn } : {}),
521
+ ...(step.memory.budget.maxTokensOut !== undefined ? { max_tokens_out: step.memory.budget.maxTokensOut } : {}),
522
+ ...(step.memory.budget.maxDollars !== undefined ? { max_dollars: step.memory.budget.maxDollars } : {}),
523
+ },
524
+ } } : {}),
500
525
  };
501
526
  switch (step.type) {
502
527
  case 'deterministic':
@@ -571,3 +596,11 @@ export function compileAndHash(yaml: string): { spec: FlowSpec; kernelSpec: Kern
571
596
  }
572
597
 
573
598
  export { SPEC_SCHEMA_VERSION, canonicalize, specHash };
599
+
600
+ function kernelRequirementsToAuthoring(value: unknown, at: string): unknown {
601
+ const requirements = requireKernelObject(value, ['execution', 'workspace', 'network', 'expected_duration_ms', 'preference'], at);
602
+ return {
603
+ ...copyDefined(requirements, ['execution', 'workspace', 'network', 'preference']),
604
+ ...(requirements['expected_duration_ms'] !== undefined ? { expectedDurationMs: requirements['expected_duration_ms'] } : {}),
605
+ };
606
+ }
@@ -0,0 +1,336 @@
1
+ // Reading and validating relayflowd's connection file, and deciding whether
2
+ // anything is actually serving a data dir (kernel/DAEMON-LIFECYCLE.md §§1-2).
3
+ //
4
+ // Split from daemon-lifecycle.ts along the same seam ../relay uses between
5
+ // broker-connection.ts and broker-lifecycle.ts: this file answers "what is
6
+ // there?", that one answers "put something there". Neither approaches the
7
+ // 500-line smell in AGENTS.md rule 1.
8
+ //
9
+ // The governing rule, from §1: **the socket is the authority;
10
+ // `connection.json` is an index over it.** A file is never believed on its
11
+ // own. Every "attach" in this module is backed by a `hello` that a live
12
+ // daemon answered on the socket path recomputed from `--data-dir` — never by
13
+ // what a file says about itself.
14
+
15
+ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
16
+ import {
17
+ closeSync,
18
+ mkdirSync,
19
+ openSync,
20
+ readFileSync,
21
+ rmSync,
22
+ } from 'node:fs';
23
+ import { join, resolve } from 'node:path';
24
+ import { JournalClient, JournalProtocolError } from './journal-client.js';
25
+ import { PROTOCOL_VERSION } from './protocol.js';
26
+ import {
27
+ defaultRelayflowdPathDeps,
28
+ resolveRelayflowdBinary,
29
+ } from './relayflowd-path.js';
30
+
31
+ /**
32
+ * The `hello` probe runs before every command, so it must be short. §4 gives
33
+ * `connect()` the same bound for the same reason: a listener that accepts and
34
+ * never answers must not hold the CLI for the 30s request default.
35
+ */
36
+ export const PROBE_TIMEOUT_MS = 2_000;
37
+
38
+ export const CONNECTION_FILE = 'connection.json';
39
+ export const SOCKET_FILE = 'relayflowd.sock';
40
+ export const DAEMON_LOG_FILE = 'relayflowd.log';
41
+
42
+ /** `<data-dir>/connection.json`, exactly the shape in §1. */
43
+ export interface DaemonConnection {
44
+ socket_path: string;
45
+ pid: number;
46
+ version: string;
47
+ protocol: number;
48
+ started_at_ms: number;
49
+ }
50
+
51
+ /** Why an existing `connection.json` was not believed (§2's four cases). */
52
+ export type StaleReason =
53
+ /** §2 step 3: the file names a socket that is not this data dir's. */
54
+ | 'socket_path_mismatch'
55
+ /** §2 row 1: nothing serving, and the pid is gone. A corpse. */
56
+ | 'dead_pid_dead_socket'
57
+ /** §2 row 3: pid alive but nothing answers — mid-boot, or a reused pid. */
58
+ | 'live_pid_dead_socket';
59
+
60
+ /** Closed refusal taxonomy for the lifecycle itself. Mirrored in failure-kinds.ts. */
61
+ export type DaemonFailureKind =
62
+ | 'daemon_unreachable'
63
+ | 'relayflowd_not_found'
64
+ | 'daemon_start_failed'
65
+ | 'daemon_start_timeout';
66
+
67
+ export type DaemonState =
68
+ | { kind: 'attached'; socketPath: string; connection: DaemonConnection | null; warning?: string }
69
+ | { kind: 'absent' }
70
+ | { kind: 'stale'; reason: StaleReason; message: string }
71
+ | { kind: 'incompatible'; protocol: number }
72
+ /**
73
+ * `ensureDaemon` only. §4's sketch types `ensureDaemon` as returning a
74
+ * `DaemonState` while §3 branches D.b/D.d refuse with kinds that union has
75
+ * no member for; this is that refusal channel, kept as data rather than a
76
+ * thrown error so it reads like the rest of cli/run.ts.
77
+ */
78
+ | { kind: 'unavailable'; failure: DaemonFailureKind; message: string };
79
+
80
+ /** A probe of the socket itself — the only check that proves something serves. */
81
+ export interface SocketProbe {
82
+ reachable: boolean;
83
+ /**
84
+ * The protocol the daemon reported. Absent when something is demonstrably
85
+ * serving the socket but would not say — see `probeSocket`.
86
+ */
87
+ protocol?: number;
88
+ }
89
+
90
+ /**
91
+ * Injectable seams, following the `deps` pattern of the ../relay file this is
92
+ * modelled on. Unit tests reach no real filesystem and spawn no process.
93
+ */
94
+ export interface DaemonLifecycleDeps {
95
+ readFile(path: string): string | null;
96
+ removeFile(path: string): void;
97
+ makeDirectory(path: string): void;
98
+ openAppend(path: string): number;
99
+ closeFd(fd: number): void;
100
+ readTail(path: string, bytes: number): string;
101
+ killProcess(pid: number, signal: number): void;
102
+ spawnProcess(command: string, args: readonly string[], options: SpawnOptions): ChildProcess;
103
+ resolveBinary(): string;
104
+ probe(socketPath: string, timeoutMs: number): Promise<SocketProbe>;
105
+ now(): number;
106
+ sleep(ms: number): Promise<void>;
107
+ }
108
+
109
+ export const defaultDaemonLifecycleDeps: DaemonLifecycleDeps = {
110
+ readFile(path) {
111
+ try {
112
+ return readFileSync(path, 'utf8');
113
+ } catch {
114
+ return null;
115
+ }
116
+ },
117
+ removeFile(path) {
118
+ rmSync(path, { force: true });
119
+ },
120
+ makeDirectory(path) {
121
+ mkdirSync(path, { recursive: true });
122
+ },
123
+ openAppend: (path) => openSync(path, 'a'),
124
+ closeFd: (fd) => closeSync(fd),
125
+ readTail(path, bytes) {
126
+ try {
127
+ const content = readFileSync(path, 'utf8');
128
+ return content.length <= bytes ? content : content.slice(-bytes);
129
+ } catch {
130
+ return '';
131
+ }
132
+ },
133
+ killProcess: (pid, signal) => {
134
+ process.kill(pid, signal);
135
+ },
136
+ spawnProcess: (command, args, options) => spawn(command, args as string[], options),
137
+ resolveBinary: () => resolveRelayflowdBinary(defaultRelayflowdPathDeps),
138
+ probe: (socketPath, timeoutMs) => probeSocket(socketPath, timeoutMs),
139
+ now: () => Date.now(),
140
+ // Deliberately NOT unref'd: an unref'd timer lets Node exit the event loop
141
+ // mid-poll, which would abandon the await and end the CLI with no report.
142
+ // The deadline in `pollForDaemon` is what bounds this wait.
143
+ sleep: (ms) => new Promise((done) => {
144
+ setTimeout(done, ms);
145
+ }),
146
+ };
147
+
148
+ export function socketPathFor(dataDir: string): string {
149
+ return join(resolve(dataDir), SOCKET_FILE);
150
+ }
151
+
152
+ export function connectionPathFor(dataDir: string): string {
153
+ return join(resolve(dataDir), CONNECTION_FILE);
154
+ }
155
+
156
+ /**
157
+ * §2 steps 1-2. Absent, unparseable, and wrongly-typed all read the same:
158
+ * `null`. Unknown fields are ignored, so adding one later is not breaking.
159
+ * The `socket_path` agreement check is §2 step 3 and lives in `checkDaemon`,
160
+ * which knows the data dir the path must agree with.
161
+ */
162
+ export function readConnectionFile(
163
+ dataDir: string,
164
+ deps: DaemonLifecycleDeps = defaultDaemonLifecycleDeps,
165
+ ): DaemonConnection | null {
166
+ const raw = deps.readFile(connectionPathFor(dataDir));
167
+ if (raw === null) return null;
168
+ let parsed: unknown;
169
+ try {
170
+ parsed = JSON.parse(raw);
171
+ } catch {
172
+ return null;
173
+ }
174
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
175
+ const record = parsed as Record<string, unknown>;
176
+ const socketPath = record['socket_path'];
177
+ const pid = record['pid'];
178
+ const version = record['version'];
179
+ const protocol = record['protocol'];
180
+ const startedAtMs = record['started_at_ms'];
181
+ if (typeof socketPath !== 'string' || socketPath.length === 0) return null;
182
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null;
183
+ if (typeof version !== 'string') return null;
184
+ if (typeof protocol !== 'number' || !Number.isInteger(protocol)) return null;
185
+ if (typeof startedAtMs !== 'number' || !Number.isInteger(startedAtMs) || startedAtMs <= 0) {
186
+ return null;
187
+ }
188
+ return {
189
+ socket_path: socketPath,
190
+ pid,
191
+ version,
192
+ protocol,
193
+ started_at_ms: startedAtMs,
194
+ };
195
+ }
196
+
197
+ /**
198
+ * §2 step 4. **`EPERM` means alive** — the process exists and is owned by
199
+ * another user. ../relay's `isProcessRunning` treats every throw as dead,
200
+ * which would spawn a second daemon over a live one owned by someone else.
201
+ * That bug is not ported.
202
+ */
203
+ export function isProcessAlive(
204
+ pid: number,
205
+ deps: DaemonLifecycleDeps = defaultDaemonLifecycleDeps,
206
+ ): boolean {
207
+ if (!Number.isInteger(pid) || pid <= 0) return false;
208
+ try {
209
+ deps.killProcess(pid, 0);
210
+ return true;
211
+ } catch (error) {
212
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
213
+ }
214
+ }
215
+
216
+ /**
217
+ * §2 step 5. Connect, `hello`, close. A refused connection, a connect timeout,
218
+ * or a `hello` that does not answer inside the probe timeout is a failed
219
+ * probe. This is the only check that proves something is *serving*.
220
+ *
221
+ * A `hello` that answers with a structured REFUSAL is not a failed probe. §2
222
+ * step 5 was written against silence, and silence is what it must catch;
223
+ * something that returns `{ ok: false, error: ... }` is unmistakably a live
224
+ * server, and calling it absent would spawn a second daemon over it — the one
225
+ * outcome this whole design exists to prevent. It is reported as reachable
226
+ * with no protocol, which leaves the refusal to be reported by the command's
227
+ * own `hello`, where it becomes a `protocol_error` naming the server's code
228
+ * instead of a lifecycle guess.
229
+ */
230
+ export async function probeSocket(
231
+ socketPath: string,
232
+ timeoutMs: number = PROBE_TIMEOUT_MS,
233
+ ): Promise<SocketProbe> {
234
+ const client = new JournalClient(socketPath, {
235
+ connectTimeoutMs: timeoutMs,
236
+ requestTimeoutMs: timeoutMs,
237
+ });
238
+ try {
239
+ await client.connect();
240
+ const hello = await client.hello('flows-probe');
241
+ return { reachable: true, protocol: hello.protocol };
242
+ } catch (error) {
243
+ return error instanceof JournalProtocolError ? { reachable: true } : { reachable: false };
244
+ } finally {
245
+ client.close();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * §2, decided as one function. The socket is probed at the path recomputed
251
+ * from `--data-dir`, never at the path the file names, so a file that points
252
+ * elsewhere is stale rather than a redirect.
253
+ *
254
+ * One addition to §2's table, licensed by §1's governing rule: a socket that
255
+ * answers `hello` with **no** connection file at all is an attach, not an
256
+ * absence. §2 row 2 already attaches on the socket's authority over a file
257
+ * describing a dead pid; a missing file is strictly less evidence against the
258
+ * socket than a stale one. It also keeps the CLI honest about a daemon started
259
+ * from a build that predates the connection file.
260
+ */
261
+ export async function checkDaemon(
262
+ dataDir: string,
263
+ deps: DaemonLifecycleDeps = defaultDaemonLifecycleDeps,
264
+ ): Promise<DaemonState> {
265
+ const socketPath = socketPathFor(dataDir);
266
+ const connection = readConnectionFile(dataDir, deps);
267
+ const agrees = connection !== null && connection.socket_path === socketPath;
268
+
269
+ // §2's reason for carrying `protocol` in the file: an incompatible daemon
270
+ // owning this data dir is decided without a round trip.
271
+ if (agrees && connection.protocol !== PROTOCOL_VERSION) {
272
+ return { kind: 'incompatible', protocol: connection.protocol };
273
+ }
274
+
275
+ const probe = await deps.probe(socketPath, PROBE_TIMEOUT_MS);
276
+ if (probe.reachable) {
277
+ // Only a KNOWN mismatch refuses. An undefined protocol means the daemon
278
+ // is serving but declined to say, which the command's own `hello` will
279
+ // report far better than this function could.
280
+ if (probe.protocol !== undefined && probe.protocol !== PROTOCOL_VERSION) {
281
+ return { kind: 'incompatible', protocol: probe.protocol };
282
+ }
283
+ if (!agrees) {
284
+ // No warning when the file is merely ABSENT: that is the ordinary state
285
+ // during a daemon's boot window, and the ordinary state of any daemon
286
+ // built before the connection file existed. A file that CONTRADICTS the
287
+ // socket is a different matter and is named.
288
+ const mismatch = connection === null
289
+ ? undefined
290
+ : `"${connectionPathFor(dataDir)}" names a different socket (${connection.socket_path}); attaching to "${socketPath}" instead.`;
291
+ return {
292
+ kind: 'attached',
293
+ socketPath,
294
+ connection: null,
295
+ ...(mismatch === undefined ? {} : { warning: mismatch }),
296
+ };
297
+ }
298
+ // §2 row 2: dead pid, live socket. Something is serving; do not spawn.
299
+ // Binding over a live daemon is the corruption this design prevents.
300
+ const warning = isProcessAlive(connection.pid, deps)
301
+ ? undefined
302
+ : `${CONNECTION_FILE} names pid ${connection.pid}, which is not running, but "${socketPath}" is serving. Attaching to the socket.`;
303
+ return {
304
+ kind: 'attached',
305
+ socketPath,
306
+ connection,
307
+ ...(warning === undefined ? {} : { warning }),
308
+ };
309
+ }
310
+
311
+ // Nothing is serving.
312
+ if (connection === null) return { kind: 'absent' };
313
+ if (!agrees) {
314
+ return {
315
+ kind: 'stale',
316
+ reason: 'socket_path_mismatch',
317
+ message: `${CONNECTION_FILE} names socket "${connection.socket_path}", not "${socketPath}".`,
318
+ };
319
+ }
320
+ // §2 row 3: a live pid proves nothing — a daemon mid-boot and an unrelated
321
+ // process that inherited the pid look identical from here, and neither is
322
+ // an attach. Spawning is safe in both cases because the daemon holds the
323
+ // mutex (§3): a redundant child loses the flock and exits 3.
324
+ return isProcessAlive(connection.pid, deps)
325
+ ? {
326
+ kind: 'stale',
327
+ reason: 'live_pid_dead_socket',
328
+ message: `pid ${connection.pid} is alive but "${socketPath}" is not accepting connections.`,
329
+ }
330
+ : {
331
+ kind: 'stale',
332
+ reason: 'dead_pid_dead_socket',
333
+ message: `pid ${connection.pid} is gone and "${socketPath}" is not accepting connections.`,
334
+ };
335
+ }
336
+