@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.
- 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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Attach-or-spawn for relayflowd (kernel/DAEMON-LIFECYCLE.md §3).
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle is not transport, so none of this lives in journal-client.ts:
|
|
4
|
+
// putting spawn logic in the client would make every AgentWorker, tick runner
|
|
5
|
+
// and demo conjure daemons as a side effect of connecting. The client stays
|
|
6
|
+
// fail-closed with no retry and no spawn; this module only decides how the
|
|
7
|
+
// socket gets there.
|
|
8
|
+
|
|
9
|
+
import type { ChildProcess, SpawnOptions } from 'node:child_process';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
checkDaemon,
|
|
13
|
+
connectionPathFor,
|
|
14
|
+
DAEMON_LOG_FILE,
|
|
15
|
+
defaultDaemonLifecycleDeps,
|
|
16
|
+
socketPathFor,
|
|
17
|
+
type DaemonLifecycleDeps,
|
|
18
|
+
type DaemonState,
|
|
19
|
+
} from './daemon-connection.js';
|
|
20
|
+
import { RelayflowdNotFoundError } from './relayflowd-path.js';
|
|
21
|
+
|
|
22
|
+
export * from './daemon-connection.js';
|
|
23
|
+
|
|
24
|
+
/** `relayflowd serve` lost the singleton lock — someone else is serving (§3). */
|
|
25
|
+
export const EXIT_ALREADY_SERVING = 3;
|
|
26
|
+
|
|
27
|
+
/** §3: matches ../relay's DETACHED_START_READY_TIMEOUT_MS. */
|
|
28
|
+
export const DEFAULT_START_TIMEOUT_MS = 10_000;
|
|
29
|
+
/** §3: matches the existing poll in scripts/run-local-workflow.mjs:64-71. */
|
|
30
|
+
export const START_POLL_INTERVAL_MS = 50;
|
|
31
|
+
/** How much of relayflowd.log a `daemon_start_failed` refusal quotes. */
|
|
32
|
+
const LOG_TAIL_BYTES = 4_096;
|
|
33
|
+
|
|
34
|
+
export interface EnsureDaemonOptions {
|
|
35
|
+
/** `false` restores today's fail-closed behavior (`--no-spawn`). */
|
|
36
|
+
spawn?: boolean;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* §3's CLI algorithm. Attach if something is serving; otherwise spawn
|
|
42
|
+
* `relayflowd serve` detached and poll for it, bounded.
|
|
43
|
+
*
|
|
44
|
+
* The CLI never has to be clever here, and that is the load-bearing
|
|
45
|
+
* simplification: because the singleton mutex lives in the daemon
|
|
46
|
+
* (`flock` on `<data-dir>/relayflowd.lock`, §3), spawning when in doubt is
|
|
47
|
+
* always safe. A child that loses the race exits `EXIT_ALREADY_SERVING`
|
|
48
|
+
* having unlinked nothing, bound nothing and written nothing, and this loop
|
|
49
|
+
* keeps polling until the winner publishes.
|
|
50
|
+
*
|
|
51
|
+
* The CLI never terminates a daemon — not one it found, and not one it
|
|
52
|
+
* spawned.
|
|
53
|
+
*/
|
|
54
|
+
export async function ensureDaemon(
|
|
55
|
+
dataDir: string,
|
|
56
|
+
options: EnsureDaemonOptions = {},
|
|
57
|
+
deps: DaemonLifecycleDeps = defaultDaemonLifecycleDeps,
|
|
58
|
+
): Promise<DaemonState> {
|
|
59
|
+
const resolvedDataDir = resolve(dataDir);
|
|
60
|
+
const maySpawn = options.spawn ?? true;
|
|
61
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
|
62
|
+
|
|
63
|
+
const first = await checkDaemon(resolvedDataDir, deps);
|
|
64
|
+
if (first.kind === 'attached' || first.kind === 'incompatible') return first;
|
|
65
|
+
if (!maySpawn) {
|
|
66
|
+
return {
|
|
67
|
+
kind: 'unavailable',
|
|
68
|
+
failure: 'daemon_unreachable',
|
|
69
|
+
message: first.kind === 'stale' ? first.message : 'no connection file and no listening socket',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only the corpse is removed, and only when nothing is serving and its pid
|
|
74
|
+
// is gone (§2 row 1). The other stale verdicts leave the file alone on
|
|
75
|
+
// purpose: a daemon mid-boot may be about to publish, and unlinking the
|
|
76
|
+
// winner's file would strand this poll loop. The booting daemon sweeps
|
|
77
|
+
// leftovers under the lock anyway (§1 step 3), which is the only place the
|
|
78
|
+
// removal is provably safe.
|
|
79
|
+
if (first.kind === 'stale' && first.reason === 'dead_pid_dead_socket') {
|
|
80
|
+
deps.removeFile(connectionPathFor(resolvedDataDir));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let binary: string;
|
|
84
|
+
try {
|
|
85
|
+
binary = deps.resolveBinary();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
kind: 'unavailable',
|
|
89
|
+
failure: 'relayflowd_not_found',
|
|
90
|
+
message: error instanceof RelayflowdNotFoundError || error instanceof Error
|
|
91
|
+
? error.message
|
|
92
|
+
: 'relayflowd could not be located.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deps.makeDirectory(resolvedDataDir);
|
|
97
|
+
const logPath = join(resolvedDataDir, DAEMON_LOG_FILE);
|
|
98
|
+
const child = spawnDaemon(binary, resolvedDataDir, logPath, deps);
|
|
99
|
+
return pollForDaemon(resolvedDataDir, child, logPath, timeoutMs, deps);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface SpawnedDaemon {
|
|
103
|
+
exit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
|
104
|
+
error: Error | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `detached: true` puts the child in its own session and process group. Both
|
|
109
|
+
* consequences are required: it outlives this CLI, and a Ctrl-C sent to the
|
|
110
|
+
* CLI's process group does not reach it.
|
|
111
|
+
*
|
|
112
|
+
* stdio never inherits the CLI's. A daemon holding the CLI's stdout would
|
|
113
|
+
* interleave its own output with `flows run --json`'s single report object,
|
|
114
|
+
* and would hold the pipe open after the CLI exits, hanging anything reading
|
|
115
|
+
* it. stderr goes to `<data-dir>/relayflowd.log` so a failed start has
|
|
116
|
+
* evidence to quote.
|
|
117
|
+
*/
|
|
118
|
+
function spawnDaemon(
|
|
119
|
+
binary: string,
|
|
120
|
+
dataDir: string,
|
|
121
|
+
logPath: string,
|
|
122
|
+
deps: DaemonLifecycleDeps,
|
|
123
|
+
): SpawnedDaemon {
|
|
124
|
+
const state: SpawnedDaemon = { exit: undefined, error: undefined };
|
|
125
|
+
const log = deps.openAppend(logPath);
|
|
126
|
+
try {
|
|
127
|
+
const child = deps.spawnProcess(binary, ['--data-dir', dataDir, 'serve'], {
|
|
128
|
+
detached: true,
|
|
129
|
+
stdio: ['ignore', 'ignore', log],
|
|
130
|
+
env: process.env,
|
|
131
|
+
cwd: process.cwd(),
|
|
132
|
+
});
|
|
133
|
+
child.once('error', (error: Error) => {
|
|
134
|
+
state.error = error;
|
|
135
|
+
});
|
|
136
|
+
child.once('exit', (code: number | null, signal: NodeJS.Signals | null) => {
|
|
137
|
+
state.exit = { code, signal };
|
|
138
|
+
});
|
|
139
|
+
child.unref();
|
|
140
|
+
} finally {
|
|
141
|
+
// The child holds its own duplicate of this descriptor; keeping the
|
|
142
|
+
// parent's copy open would leak one per invocation.
|
|
143
|
+
deps.closeFd(log);
|
|
144
|
+
}
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function pollForDaemon(
|
|
149
|
+
dataDir: string,
|
|
150
|
+
child: SpawnedDaemon,
|
|
151
|
+
logPath: string,
|
|
152
|
+
timeoutMs: number,
|
|
153
|
+
deps: DaemonLifecycleDeps,
|
|
154
|
+
): Promise<DaemonState> {
|
|
155
|
+
const deadline = deps.now() + timeoutMs;
|
|
156
|
+
for (;;) {
|
|
157
|
+
if (child.error !== undefined) {
|
|
158
|
+
return {
|
|
159
|
+
kind: 'unavailable',
|
|
160
|
+
failure: 'daemon_start_failed',
|
|
161
|
+
message: `relayflowd could not be started: ${child.error.message}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// §3 branch D.a: exit 3 means this child lost a benign race for the data
|
|
165
|
+
// dir's lock. The winner is coming up. Keep polling; do not respawn, and
|
|
166
|
+
// do not report failure.
|
|
167
|
+
if (
|
|
168
|
+
child.exit !== undefined
|
|
169
|
+
&& child.exit.code !== null
|
|
170
|
+
&& child.exit.code !== 0
|
|
171
|
+
&& child.exit.code !== EXIT_ALREADY_SERVING
|
|
172
|
+
) {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'unavailable',
|
|
175
|
+
failure: 'daemon_start_failed',
|
|
176
|
+
message: `relayflowd exited ${child.exit.code} during startup.${logTail(logPath, deps)}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const state = await checkDaemon(dataDir, deps);
|
|
181
|
+
if (state.kind === 'attached' || state.kind === 'incompatible') return state;
|
|
182
|
+
|
|
183
|
+
if (deps.now() >= deadline) {
|
|
184
|
+
return {
|
|
185
|
+
kind: 'unavailable',
|
|
186
|
+
failure: 'daemon_start_timeout',
|
|
187
|
+
message: `relayflowd did not start serving "${socketPathFor(dataDir)}" within ${timeoutMs}ms.`
|
|
188
|
+
+ logTail(logPath, deps),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
await deps.sleep(START_POLL_INTERVAL_MS);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function logTail(logPath: string, deps: DaemonLifecycleDeps): string {
|
|
196
|
+
const tail = deps.readTail(logPath, LOG_TAIL_BYTES).trim();
|
|
197
|
+
return tail.length === 0 ? ` See "${logPath}".` : ` Last output in "${logPath}":\n${tail}`;
|
|
198
|
+
}
|
package/src/failure-kinds.ts
CHANGED
|
@@ -52,18 +52,42 @@ export const PREFLIGHT_WARNING_KINDS = [
|
|
|
52
52
|
'vacuous_gate',
|
|
53
53
|
] as const;
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Closed outcome taxonomy owned by the `flows run` / `flows resume` surface.
|
|
57
|
+
*
|
|
58
|
+
* The four daemon-lifecycle kinds after `daemon_unreachable` are the
|
|
59
|
+
* attach-or-spawn refusals from kernel/DAEMON-LIFECYCLE.md §3. They split what
|
|
60
|
+
* used to be one message: `daemon_unreachable` now means only "nothing is
|
|
61
|
+
* serving and this invocation was told not to start one" (`--no-spawn`), while
|
|
62
|
+
* a spawn that was attempted and did not produce a serving daemon names which
|
|
63
|
+
* step failed. All of them are still exit 2 — refused before a journal write.
|
|
64
|
+
*/
|
|
56
65
|
export const RUN_FAILURE_KINDS = [
|
|
57
66
|
'daemon_unreachable',
|
|
67
|
+
'daemon_protocol_mismatch',
|
|
68
|
+
'daemon_start_failed',
|
|
69
|
+
'daemon_start_timeout',
|
|
70
|
+
'relayflowd_not_found',
|
|
58
71
|
'protocol_error',
|
|
59
72
|
'run_parked',
|
|
60
73
|
'run_unavailable',
|
|
61
74
|
] as const;
|
|
62
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Non-refusing outcomes of the attach step. `connection_file_stale` is
|
|
78
|
+
* DAEMON-LIFECYCLE.md §2 row 2: the socket answered while `connection.json`
|
|
79
|
+
* described a process that is gone. The socket is the authority, so this
|
|
80
|
+
* attaches — but it says so rather than passing in silence.
|
|
81
|
+
*/
|
|
82
|
+
export const RUN_WARNING_KINDS = [
|
|
83
|
+
'connection_file_stale',
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
63
86
|
export type PreflightFailureKind = (typeof PREFLIGHT_FAILURE_KINDS)[number];
|
|
64
87
|
export type CheckFailureKind = (typeof CHECK_FAILURE_KINDS)[number];
|
|
65
88
|
export type PreflightWarningKind = (typeof PREFLIGHT_WARNING_KINDS)[number];
|
|
66
89
|
export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
|
|
90
|
+
export type RunWarningKind = (typeof RUN_WARNING_KINDS)[number];
|
|
67
91
|
|
|
68
92
|
const CHECK_FAILURE_KIND_SET: ReadonlySet<string> = new Set(CHECK_FAILURE_KINDS);
|
|
69
93
|
const RUN_FAILURE_KIND_SET: ReadonlySet<string> = new Set(RUN_FAILURE_KINDS);
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type {
|
|
|
7
7
|
AgentSurfaces,
|
|
8
8
|
BaseStepSpec,
|
|
9
9
|
BudgetSpec,
|
|
10
|
+
MemorySpec,
|
|
11
|
+
KernelMemorySpec,
|
|
10
12
|
DeterministicStepSpec,
|
|
11
13
|
ExitCodeGate,
|
|
12
14
|
FlowSpec,
|
|
@@ -33,6 +35,8 @@ export type {
|
|
|
33
35
|
StreamSurface,
|
|
34
36
|
StepSpec,
|
|
35
37
|
StepType,
|
|
38
|
+
PlacementRequirements,
|
|
39
|
+
KernelPlacementRequirements,
|
|
36
40
|
TriggerSpec,
|
|
37
41
|
VerificationGateType,
|
|
38
42
|
VerificationSpec,
|
|
@@ -116,6 +120,8 @@ export type {
|
|
|
116
120
|
StepCompleteParams,
|
|
117
121
|
StepCompleteResult,
|
|
118
122
|
StepDispatchEvent,
|
|
123
|
+
RoutingDecision,
|
|
124
|
+
MemoryInjectedPayload,
|
|
119
125
|
StepHeartbeatParams,
|
|
120
126
|
StepHeartbeatResult,
|
|
121
127
|
StreamAppendParams,
|
package/src/journal-client.ts
CHANGED
|
@@ -27,6 +27,17 @@ import type { KernelRunSpec, StepType } from './spec.js';
|
|
|
27
27
|
export interface JournalClientOptions {
|
|
28
28
|
/** Override the timeout for bounded protocol requests (ms). Default 30000. */
|
|
29
29
|
requestTimeoutMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Bound on `connect()` (ms). Default 2000.
|
|
32
|
+
*
|
|
33
|
+
* `connect()` used to have no timer at all, which was survivable while the
|
|
34
|
+
* only caller was a command that had already decided a daemon was there.
|
|
35
|
+
* `daemon-lifecycle.ts` probes the socket before every command, and a
|
|
36
|
+
* listener that accepts but never answers would otherwise hold the CLI for
|
|
37
|
+
* the 30s request default before it could decide to spawn
|
|
38
|
+
* (kernel/DAEMON-LIFECYCLE.md §4).
|
|
39
|
+
*/
|
|
40
|
+
connectTimeoutMs?: number;
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
interface Pending {
|
|
@@ -51,6 +62,7 @@ export class JournalClient extends EventEmitter {
|
|
|
51
62
|
private buffer = '';
|
|
52
63
|
private readonly pending = new Map<string, Pending>();
|
|
53
64
|
private readonly requestTimeoutMs: number;
|
|
65
|
+
private readonly connectTimeoutMs: number;
|
|
54
66
|
|
|
55
67
|
constructor(
|
|
56
68
|
private readonly socketPath: string,
|
|
@@ -58,6 +70,7 @@ export class JournalClient extends EventEmitter {
|
|
|
58
70
|
) {
|
|
59
71
|
super();
|
|
60
72
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
73
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 2_000;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/** Open the unix socket connection. Rejects on connect failure (fail-closed). */
|
|
@@ -65,13 +78,21 @@ export class JournalClient extends EventEmitter {
|
|
|
65
78
|
return new Promise((resolve, reject) => {
|
|
66
79
|
if (this.socket) return resolve();
|
|
67
80
|
const socket = createConnection({ path: this.socketPath });
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
socket.removeAllListeners();
|
|
83
|
+
socket.destroy();
|
|
84
|
+
this.failAll(new Error(`journal client: connect timed out after ${this.connectTimeoutMs}ms`));
|
|
85
|
+
reject(new Error(`journal client: connect timed out after ${this.connectTimeoutMs}ms`));
|
|
86
|
+
}, this.connectTimeoutMs);
|
|
68
87
|
const onError = (err: Error): void => {
|
|
88
|
+
clearTimeout(timer);
|
|
69
89
|
socket.removeAllListeners();
|
|
70
90
|
this.failAll(err);
|
|
71
91
|
reject(new Error(`journal client: connect failed: ${err.message}`));
|
|
72
92
|
};
|
|
73
93
|
socket.once('error', onError);
|
|
74
94
|
socket.once('connect', () => {
|
|
95
|
+
clearTimeout(timer);
|
|
75
96
|
socket.removeListener('error', onError);
|
|
76
97
|
socket.on('error', (err) => this.failAll(err));
|
|
77
98
|
socket.on('data', (chunk) => this.onData(chunk));
|
package/src/protocol.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// This module is the typed wire surface; `journal-client.ts` implements it.
|
|
11
11
|
|
|
12
|
-
import type { KernelRunSpec, StepType } from './spec.js';
|
|
12
|
+
import type { KernelRunSpec, KernelMemorySpec, StepType } from './spec.js';
|
|
13
13
|
|
|
14
14
|
/** Stamped per segment; readers read every past version, writers write newest. */
|
|
15
15
|
export const PROTOCOL_VERSION = 0 as const;
|
|
@@ -164,7 +164,25 @@ export interface WakeContext {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
/** Server then pushes `step.dispatch` events to the attached worker. */
|
|
167
|
+
export interface MemoryInjectedPayload {
|
|
168
|
+
request: KernelMemorySpec;
|
|
169
|
+
pack: unknown;
|
|
170
|
+
budget: { tokens_in: number; tokens_out: number; dollars: string };
|
|
171
|
+
provider: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RoutingDecision {
|
|
175
|
+
profile: string;
|
|
176
|
+
provider: string;
|
|
177
|
+
fallbacks_attempted: string[];
|
|
178
|
+
workspace?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
export interface StepDispatchEvent {
|
|
182
|
+
/** Durable choice; optional only for older kernel protocol compatibility. */
|
|
183
|
+
routing?: RoutingDecision;
|
|
184
|
+
/** Already journaled and charged; completion usage excludes this cost. */
|
|
185
|
+
memory?: MemoryInjectedPayload;
|
|
168
186
|
run_id: string;
|
|
169
187
|
step_id: string;
|
|
170
188
|
attempt: number;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Resolving the `relayflowd` binary (kernel/DAEMON-LIFECYCLE.md §3.1).
|
|
2
|
+
//
|
|
3
|
+
// Never PATH alone. A version manager (nvm, volta, asdf, mise) can launch Node
|
|
4
|
+
// with a minimal PATH, and that is exactly the case where a `which` lookup
|
|
5
|
+
// fails to see the binary the installer placed next to its own launcher. The
|
|
6
|
+
// multi-anchor order below is ported from ../relay's
|
|
7
|
+
// packages/harness-driver/src/broker-path.ts for that reason.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
/** How far up from cwd a source checkout is searched for `kernel/Cargo.toml`. */
|
|
16
|
+
const SOURCE_CHECKOUT_MAX_DEPTH = 8;
|
|
17
|
+
|
|
18
|
+
/** Refusal from {@link resolveRelayflowdBinary}; carries every anchor tried. */
|
|
19
|
+
export class RelayflowdNotFoundError extends Error {
|
|
20
|
+
readonly attempts: readonly string[];
|
|
21
|
+
|
|
22
|
+
constructor(message: string, attempts: readonly string[]) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'RelayflowdNotFoundError';
|
|
25
|
+
this.attempts = attempts;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Injectable seams. Unit tests reach no real filesystem and spawn nothing. */
|
|
30
|
+
export interface RelayflowdPathDeps {
|
|
31
|
+
env: NodeJS.ProcessEnv;
|
|
32
|
+
/** `process.argv[1]` — the running `flows` entrypoint, possibly a symlink. */
|
|
33
|
+
entrypoint: string | undefined;
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** This module's own directory, the third `createRequire` anchor. */
|
|
36
|
+
moduleDir: string;
|
|
37
|
+
platform: string;
|
|
38
|
+
arch: string;
|
|
39
|
+
isExecutable(path: string): boolean;
|
|
40
|
+
exists(path: string): boolean;
|
|
41
|
+
realpath(path: string): string;
|
|
42
|
+
/** `require.resolve(specifier)` from `anchor`, or null when unresolvable. */
|
|
43
|
+
resolveFrom(specifier: string, anchor: string): string | null;
|
|
44
|
+
/** `which`/`where` lookup, or null. */
|
|
45
|
+
which(command: string): string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const defaultRelayflowdPathDeps: RelayflowdPathDeps = {
|
|
49
|
+
env: process.env,
|
|
50
|
+
entrypoint: process.argv[1],
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
moduleDir: dirname(fileURLToPath(import.meta.url)),
|
|
53
|
+
platform: process.platform,
|
|
54
|
+
arch: process.arch,
|
|
55
|
+
isExecutable(path) {
|
|
56
|
+
try {
|
|
57
|
+
accessSync(path, constants.X_OK);
|
|
58
|
+
return statSync(path).isFile();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
exists: (path) => existsSync(path),
|
|
64
|
+
realpath(path) {
|
|
65
|
+
try {
|
|
66
|
+
return realpathSync(path);
|
|
67
|
+
} catch {
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
resolveFrom(specifier, anchor) {
|
|
72
|
+
try {
|
|
73
|
+
return createRequire(anchor).resolve(specifier);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
which(command) {
|
|
79
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
80
|
+
const found = spawnSync(finder, [command], { encoding: 'utf8' });
|
|
81
|
+
if (found.status !== 0 || typeof found.stdout !== 'string') return null;
|
|
82
|
+
const first = found.stdout.split('\n')[0]?.trim();
|
|
83
|
+
return first === undefined || first.length === 0 ? null : first;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The published runtime package for this host, e.g.
|
|
89
|
+
* `@relayflows/runtime-linux-x64` (see packages/runtime-linux-x64).
|
|
90
|
+
*/
|
|
91
|
+
export function runtimePackageName(platform: string, arch: string): string {
|
|
92
|
+
return `@relayflows/runtime-${platform}-${arch}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Locate `relayflowd`, or refuse.
|
|
97
|
+
*
|
|
98
|
+
* Order is §3.1's, and the first anchor is a hard stop rather than a
|
|
99
|
+
* preference: an operator who exported `RELAYFLOWD_BIN` meant it, so a value
|
|
100
|
+
* that is not executable refuses instead of quietly falling through to PATH.
|
|
101
|
+
* Silent fallback is what AGENTS.md rule 4 forbids.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveRelayflowdBinary(
|
|
104
|
+
deps: RelayflowdPathDeps = defaultRelayflowdPathDeps,
|
|
105
|
+
): string {
|
|
106
|
+
const attempts: string[] = [];
|
|
107
|
+
|
|
108
|
+
const override = deps.env['RELAYFLOWD_BIN'];
|
|
109
|
+
if (override !== undefined && override.length > 0) {
|
|
110
|
+
if (deps.isExecutable(override)) return override;
|
|
111
|
+
throw new RelayflowdNotFoundError(
|
|
112
|
+
`RELAYFLOWD_BIN is set to "${override}", which is not an executable file. `
|
|
113
|
+
+ 'Point it at a relayflowd binary or unset it; it is not ignored.',
|
|
114
|
+
[`RELAYFLOWD_BIN=${override}`],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const candidate of [
|
|
119
|
+
() => siblingOfEntrypoint(deps),
|
|
120
|
+
() => runtimePackageBinary(deps),
|
|
121
|
+
() => sourceCheckoutBinary(deps),
|
|
122
|
+
() => deps.which('relayflowd'),
|
|
123
|
+
]) {
|
|
124
|
+
const found = candidate();
|
|
125
|
+
if (found === null) continue;
|
|
126
|
+
attempts.push(found);
|
|
127
|
+
if (deps.isExecutable(found)) return found;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const runtime = runtimePackageName(deps.platform, deps.arch);
|
|
131
|
+
throw new RelayflowdNotFoundError(
|
|
132
|
+
`No relayflowd binary could be found. Install the runtime package for this host `
|
|
133
|
+
+ `(${runtime}), or set RELAYFLOWD_BIN to a relayflowd executable.`
|
|
134
|
+
+ (attempts.length === 0 ? '' : ` Tried: ${attempts.join(', ')}.`),
|
|
135
|
+
attempts,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The published layout: `bin/flows` and `bin/relayflowd` side by side.
|
|
141
|
+
* `realpath` matters — a package-manager launcher exposes the entrypoint as a
|
|
142
|
+
* symlink whose target sits in the real install tree, and the sibling is next
|
|
143
|
+
* to the target, not next to the link.
|
|
144
|
+
*/
|
|
145
|
+
function siblingOfEntrypoint(deps: RelayflowdPathDeps): string | null {
|
|
146
|
+
if (deps.entrypoint === undefined || deps.entrypoint.length === 0) return null;
|
|
147
|
+
return join(dirname(deps.realpath(deps.entrypoint)), 'relayflowd');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The optional-dependency package, tried from several `createRequire` anchors.
|
|
152
|
+
* Several because a globally installed `flows` resolving a per-project optional
|
|
153
|
+
* dependency sits outside the consumer's `node_modules` — broker-path.ts:84-106
|
|
154
|
+
* verbatim in its reasoning.
|
|
155
|
+
*/
|
|
156
|
+
function runtimePackageBinary(deps: RelayflowdPathDeps): string | null {
|
|
157
|
+
const specifier = `${runtimePackageName(deps.platform, deps.arch)}/package.json`;
|
|
158
|
+
const anchors = [
|
|
159
|
+
deps.moduleDir.endsWith('/') ? deps.moduleDir : `${deps.moduleDir}/`,
|
|
160
|
+
...(deps.entrypoint === undefined ? [] : [deps.entrypoint]),
|
|
161
|
+
join(deps.cwd, 'package.json'),
|
|
162
|
+
];
|
|
163
|
+
for (const anchor of anchors) {
|
|
164
|
+
const manifest = deps.resolveFrom(specifier, anchor);
|
|
165
|
+
if (manifest === null) continue;
|
|
166
|
+
return join(dirname(manifest), 'bin', 'relayflowd');
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A source checkout: the nearest ancestor of cwd holding `kernel/Cargo.toml`,
|
|
173
|
+
* then release before debug. Bounded, so a deep cwd cannot walk to `/`.
|
|
174
|
+
*/
|
|
175
|
+
function sourceCheckoutBinary(deps: RelayflowdPathDeps): string | null {
|
|
176
|
+
let directory = resolve(deps.cwd);
|
|
177
|
+
for (let depth = 0; depth < SOURCE_CHECKOUT_MAX_DEPTH; depth += 1) {
|
|
178
|
+
if (deps.exists(join(directory, 'kernel', 'Cargo.toml'))) {
|
|
179
|
+
for (const profile of ['release', 'debug']) {
|
|
180
|
+
const candidate = join(directory, 'kernel', 'target', profile, 'relayflowd');
|
|
181
|
+
if (deps.isExecutable(candidate)) return candidate;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const parent = dirname(directory);
|
|
186
|
+
if (parent === directory) return null;
|
|
187
|
+
directory = parent;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
package/src/spec.ts
CHANGED
|
@@ -48,7 +48,7 @@ export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate;
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Agent-step recovery modes (RFC Appendix A rule 4). Default is `reset`.
|
|
51
|
-
*
|
|
51
|
+
* These recovery modes apply to agent steps; deterministic steps retry their commands.
|
|
52
52
|
*/
|
|
53
53
|
export type RecoveryMode = 'reset' | 'inspect' | 'manual';
|
|
54
54
|
|
|
@@ -94,8 +94,38 @@ export interface BudgetSpec {
|
|
|
94
94
|
maxDollars?: string;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Slice 1 records a fixed pack; retrieval is supplied by a future provider. */
|
|
98
|
+
export interface MemorySpec {
|
|
99
|
+
scope: 'script' | 'agent';
|
|
100
|
+
query: string;
|
|
101
|
+
budget: BudgetSpec;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface KernelMemorySpec {
|
|
105
|
+
scope: 'script' | 'agent';
|
|
106
|
+
query: string;
|
|
107
|
+
budget: KernelBudgetSpec;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Capability needs, never provider names or source revisions. */
|
|
111
|
+
export interface PlacementRequirements {
|
|
112
|
+
execution?: 'batch' | 'interactive';
|
|
113
|
+
/** Share the run tree; defaults true for deterministic steps declaring requirements. */
|
|
114
|
+
workspace?: boolean;
|
|
115
|
+
/** True requests connectivity; false does not impose a network deny policy. */
|
|
116
|
+
network?: boolean;
|
|
117
|
+
expectedDurationMs?: number;
|
|
118
|
+
preference?: 'cost' | 'latency' | 'reliability' | 'balanced';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface KernelPlacementRequirements extends Omit<PlacementRequirements, 'expectedDurationMs'> {
|
|
122
|
+
expected_duration_ms?: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
97
125
|
/** Fields shared by every step on the ladder. */
|
|
98
126
|
export interface BaseStepSpec {
|
|
127
|
+
requirements?: PlacementRequirements;
|
|
128
|
+
memory?: MemorySpec;
|
|
99
129
|
/** Stable step identity; journaled as `step_id` and hashed into the idempotency key. */
|
|
100
130
|
id: string;
|
|
101
131
|
type: StepType;
|
|
@@ -108,7 +138,7 @@ export interface BaseStepSpec {
|
|
|
108
138
|
/**
|
|
109
139
|
* Rung 1 — a pure script. Executed by the `relayflowd` binary: spawn command,
|
|
110
140
|
* capture stdout/exit code. Output = `{exit_code, stdout_tail}`. Gate-1
|
|
111
|
-
* deterministic steps
|
|
141
|
+
* deterministic steps with placement requirements pin their worktree base commit.
|
|
112
142
|
*/
|
|
113
143
|
export interface DeterministicStepSpec extends BaseStepSpec {
|
|
114
144
|
type: 'deterministic';
|
|
@@ -272,6 +302,8 @@ export interface KernelVerificationSpec {
|
|
|
272
302
|
}
|
|
273
303
|
|
|
274
304
|
export interface KernelStepCommon {
|
|
305
|
+
requirements?: KernelPlacementRequirements;
|
|
306
|
+
memory?: KernelMemorySpec;
|
|
275
307
|
id: string;
|
|
276
308
|
depends_on: string[];
|
|
277
309
|
max_iterations: number;
|