@relayflows/sdk 2.0.1 → 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.
- package/dist/cli/daemon-refusal.d.ts +12 -0
- package/dist/cli/daemon-refusal.d.ts.map +1 -0
- package/dist/cli/daemon-refusal.js +42 -0
- package/dist/cli/daemon-refusal.js.map +1 -0
- package/dist/cli/direct-run.d.ts +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +2 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +26 -4
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +51 -6
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +39 -8
- package/dist/cli.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +35 -4
- package/dist/compile.js.map +1 -1
- package/dist/daemon-connection.d.ts +127 -0
- package/dist/daemon-connection.d.ts.map +1 -0
- package/dist/daemon-connection.js +249 -0
- package/dist/daemon-connection.js.map +1 -0
- package/dist/daemon-lifecycle.d.ts +29 -0
- package/dist/daemon-lifecycle.d.ts.map +1 -0
- package/dist/daemon-lifecycle.js +152 -0
- package/dist/daemon-lifecycle.js.map +1 -0
- package/dist/failure-kinds.d.ts +19 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +23 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +12 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +10 -0
- package/dist/journal-client.js.map +1 -1
- package/dist/protocol.d.ts +21 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/relayflowd-path.d.ts +39 -0
- package/dist/relayflowd-path.d.ts.map +1 -0
- package/dist/relayflowd-path.js +162 -0
- package/dist/relayflowd-path.js.map +1 -0
- package/dist/spec.d.ts +30 -2
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/step-fields.d.ts +1 -1
- package/dist/step-fields.d.ts.map +1 -1
- package/dist/step-fields.js +2 -0
- package/dist/step-fields.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +53 -6
- package/dist/validate.js.map +1 -1
- package/dist/worker.js +6 -1
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/daemon-refusal.ts +49 -0
- package/src/cli/direct-run.ts +2 -2
- package/src/cli/run.ts +62 -9
- package/src/cli.ts +42 -15
- package/src/compile.ts +37 -4
- package/src/daemon-connection.ts +336 -0
- package/src/daemon-lifecycle.ts +198 -0
- package/src/failure-kinds.ts +25 -1
- package/src/index.ts +6 -0
- package/src/journal-client.ts +21 -0
- package/src/protocol.ts +19 -1
- package/src/relayflowd-path.ts +190 -0
- package/src/spec.ts +34 -2
- package/src/step-fields.ts +2 -0
- package/src/validate.ts +50 -6
- 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
|
|
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,
|
|
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,
|
|
101
|
+
return await classifyOutcome(client, 'run', outcome, base, socketPath, options);
|
|
91
102
|
} catch (error) {
|
|
92
|
-
return protocolFailure('run',
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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)
|
|
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
|
|
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
|
+
|