@relayflows/sdk 2.0.0
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/authored-flow-error.d.ts +9 -0
- package/dist/authored-flow-error.d.ts.map +1 -0
- package/dist/authored-flow-error.js +13 -0
- package/dist/authored-flow-error.js.map +1 -0
- package/dist/authored-flow-executor.d.ts +28 -0
- package/dist/authored-flow-executor.d.ts.map +1 -0
- package/dist/authored-flow-executor.js +203 -0
- package/dist/authored-flow-executor.js.map +1 -0
- package/dist/authored-flow-lifecycle.d.ts +61 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -0
- package/dist/authored-flow-lifecycle.js +284 -0
- package/dist/authored-flow-lifecycle.js.map +1 -0
- package/dist/authored-flow-loader.d.ts +7 -0
- package/dist/authored-flow-loader.d.ts.map +1 -0
- package/dist/authored-flow-loader.js +39 -0
- package/dist/authored-flow-loader.js.map +1 -0
- package/dist/authored-flow-operation.d.ts +43 -0
- package/dist/authored-flow-operation.d.ts.map +1 -0
- package/dist/authored-flow-operation.js +176 -0
- package/dist/authored-flow-operation.js.map +1 -0
- package/dist/authored-flow.d.ts +12 -0
- package/dist/authored-flow.d.ts.map +1 -0
- package/dist/authored-flow.js +13 -0
- package/dist/authored-flow.js.map +1 -0
- package/dist/authored-promise-graph.d.ts +67 -0
- package/dist/authored-promise-graph.d.ts.map +1 -0
- package/dist/authored-promise-graph.js +206 -0
- package/dist/authored-promise-graph.js.map +1 -0
- package/dist/backlog-picker.d.ts +55 -0
- package/dist/backlog-picker.d.ts.map +1 -0
- package/dist/backlog-picker.js +117 -0
- package/dist/backlog-picker.js.map +1 -0
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +55 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cli/check.d.ts +30 -0
- package/dist/cli/check.d.ts.map +1 -0
- package/dist/cli/check.js +324 -0
- package/dist/cli/check.js.map +1 -0
- package/dist/cli/direct-run.d.ts +3 -0
- package/dist/cli/direct-run.d.ts.map +1 -0
- package/dist/cli/direct-run.js +69 -0
- package/dist/cli/direct-run.js.map +1 -0
- package/dist/cli/hn-monitor.d.ts +107 -0
- package/dist/cli/hn-monitor.d.ts.map +1 -0
- package/dist/cli/hn-monitor.js +220 -0
- package/dist/cli/hn-monitor.js.map +1 -0
- package/dist/cli/interruptible-sleep.d.ts +13 -0
- package/dist/cli/interruptible-sleep.d.ts.map +1 -0
- package/dist/cli/interruptible-sleep.js +31 -0
- package/dist/cli/interruptible-sleep.js.map +1 -0
- package/dist/cli/run.d.ts +54 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +316 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/tick-runner.d.ts +151 -0
- package/dist/cli/tick-runner.d.ts.map +1 -0
- package/dist/cli/tick-runner.js +258 -0
- package/dist/cli/tick-runner.js.map +1 -0
- package/dist/cli-adapter.d.ts +29 -0
- package/dist/cli-adapter.d.ts.map +1 -0
- package/dist/cli-adapter.js +92 -0
- package/dist/cli-adapter.js.map +1 -0
- package/dist/cli-executable.d.ts +3 -0
- package/dist/cli-executable.d.ts.map +1 -0
- package/dist/cli-executable.js +10 -0
- package/dist/cli-executable.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +348 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +42 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +515 -0
- package/dist/compile.js.map +1 -0
- package/dist/demo-hn-monitor.d.ts +2 -0
- package/dist/demo-hn-monitor.d.ts.map +1 -0
- package/dist/demo-hn-monitor.js +118 -0
- package/dist/demo-hn-monitor.js.map +1 -0
- package/dist/dir-watcher-poller.d.ts +73 -0
- package/dist/dir-watcher-poller.d.ts.map +1 -0
- package/dist/dir-watcher-poller.js +80 -0
- package/dist/dir-watcher-poller.js.map +1 -0
- package/dist/direct-input.d.ts +10 -0
- package/dist/direct-input.d.ts.map +1 -0
- package/dist/direct-input.js +61 -0
- package/dist/direct-input.js.map +1 -0
- package/dist/failure-kinds.d.ts +25 -0
- package/dist/failure-kinds.d.ts.map +1 -0
- package/dist/failure-kinds.js +64 -0
- package/dist/failure-kinds.js.map +1 -0
- package/dist/gate-contract.d.ts +24 -0
- package/dist/gate-contract.d.ts.map +1 -0
- package/dist/gate-contract.js +50 -0
- package/dist/gate-contract.js.map +1 -0
- package/dist/hn-poller.d.ts +50 -0
- package/dist/hn-poller.d.ts.map +1 -0
- package/dist/hn-poller.js +79 -0
- package/dist/hn-poller.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/journal-client.d.ts +122 -0
- package/dist/journal-client.d.ts.map +1 -0
- package/dist/journal-client.js +277 -0
- package/dist/journal-client.js.map +1 -0
- package/dist/json-schema-bound.d.ts +7 -0
- package/dist/json-schema-bound.d.ts.map +1 -0
- package/dist/json-schema-bound.js +353 -0
- package/dist/json-schema-bound.js.map +1 -0
- package/dist/json-schema.d.ts +5 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +72 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/json-value.d.ts +6 -0
- package/dist/json-value.d.ts.map +1 -0
- package/dist/json-value.js +94 -0
- package/dist/json-value.js.map +1 -0
- package/dist/model-name.d.ts +7 -0
- package/dist/model-name.d.ts.map +1 -0
- package/dist/model-name.js +21 -0
- package/dist/model-name.js.map +1 -0
- package/dist/output-schema.d.ts +8 -0
- package/dist/output-schema.d.ts.map +1 -0
- package/dist/output-schema.js +28 -0
- package/dist/output-schema.js.map +1 -0
- package/dist/preflight.d.ts +80 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +361 -0
- package/dist/preflight.js.map +1 -0
- package/dist/protocol.d.ts +359 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/spec.d.ts +294 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +13 -0
- package/dist/spec.js.map +1 -0
- package/dist/step-dependencies.d.ts +2 -0
- package/dist/step-dependencies.d.ts.map +1 -0
- package/dist/step-dependencies.js +88 -0
- package/dist/step-dependencies.js.map +1 -0
- package/dist/step-fields.d.ts +17 -0
- package/dist/step-fields.d.ts.map +1 -0
- package/dist/step-fields.js +32 -0
- package/dist/step-fields.js.map +1 -0
- package/dist/tick-source.d.ts +205 -0
- package/dist/tick-source.d.ts.map +1 -0
- package/dist/tick-source.js +234 -0
- package/dist/tick-source.js.map +1 -0
- package/dist/unknown-keys.d.ts +7 -0
- package/dist/unknown-keys.d.ts.map +1 -0
- package/dist/unknown-keys.js +47 -0
- package/dist/unknown-keys.js.map +1 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +458 -0
- package/dist/validate.js.map +1 -0
- package/dist/work-package-consumer.d.ts +33 -0
- package/dist/work-package-consumer.d.ts.map +1 -0
- package/dist/work-package-consumer.js +44 -0
- package/dist/work-package-consumer.js.map +1 -0
- package/dist/work-package-validator.d.ts +15 -0
- package/dist/work-package-validator.d.ts.map +1 -0
- package/dist/work-package-validator.js +72 -0
- package/dist/work-package-validator.js.map +1 -0
- package/dist/worker-cli.d.ts +16 -0
- package/dist/worker-cli.d.ts.map +1 -0
- package/dist/worker-cli.js +77 -0
- package/dist/worker-cli.js.map +1 -0
- package/dist/worker.d.ts +57 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +129 -0
- package/dist/worker.js.map +1 -0
- package/dist/wrapper-runtime.d.ts +10 -0
- package/dist/wrapper-runtime.d.ts.map +1 -0
- package/dist/wrapper-runtime.js +83 -0
- package/dist/wrapper-runtime.js.map +1 -0
- package/dist/wrapper-session.d.ts +16 -0
- package/dist/wrapper-session.d.ts.map +1 -0
- package/dist/wrapper-session.js +244 -0
- package/dist/wrapper-session.js.map +1 -0
- package/package.json +50 -0
- package/src/authored-flow-error.ts +31 -0
- package/src/authored-flow-executor.ts +365 -0
- package/src/authored-flow-lifecycle.ts +344 -0
- package/src/authored-flow-loader.ts +44 -0
- package/src/authored-flow-operation.ts +252 -0
- package/src/authored-flow.ts +21 -0
- package/src/authored-promise-graph.ts +196 -0
- package/src/backlog-picker.ts +153 -0
- package/src/canonical.ts +57 -0
- package/src/cli/check.ts +391 -0
- package/src/cli/direct-run.ts +86 -0
- package/src/cli/hn-monitor.ts +286 -0
- package/src/cli/interruptible-sleep.ts +27 -0
- package/src/cli/run.ts +439 -0
- package/src/cli/tick-runner.ts +378 -0
- package/src/cli-adapter.ts +115 -0
- package/src/cli-executable.ts +12 -0
- package/src/cli.ts +364 -0
- package/src/compile.ts +573 -0
- package/src/demo-hn-monitor.ts +132 -0
- package/src/dir-watcher-poller.ts +122 -0
- package/src/direct-input.ts +69 -0
- package/src/failure-kinds.ts +77 -0
- package/src/gate-contract.ts +72 -0
- package/src/hn-poller.ts +104 -0
- package/src/index.ts +195 -0
- package/src/journal-client.ts +379 -0
- package/src/json-schema-bound.ts +357 -0
- package/src/json-schema.ts +65 -0
- package/src/json-value.ts +110 -0
- package/src/model-name.ts +20 -0
- package/src/output-schema.ts +33 -0
- package/src/preflight.ts +482 -0
- package/src/protocol.ts +364 -0
- package/src/spec.ts +343 -0
- package/src/step-dependencies.ts +100 -0
- package/src/step-fields.ts +36 -0
- package/src/tick-source.ts +334 -0
- package/src/unknown-keys.ts +52 -0
- package/src/validate.ts +503 -0
- package/src/work-package-consumer.ts +73 -0
- package/src/work-package-validator.ts +90 -0
- package/src/worker-cli.ts +110 -0
- package/src/worker.ts +150 -0
- package/src/wrapper-runtime.ts +93 -0
- package/src/wrapper-session.ts +287 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flows tick start` — drive a scheduled relayflow.
|
|
3
|
+
*
|
|
4
|
+
* A sibling of `hn-monitor.ts`, not a new species: a public function that
|
|
5
|
+
* composes the primitives directly — connect journal → hello → loop
|
|
6
|
+
* `emitDueTicks` → drain on abort → close. The differences from hn-monitor are
|
|
7
|
+
* only the ones the grid forces.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the cursor is persisted, and why that is the whole point
|
|
10
|
+
*
|
|
11
|
+
* `emitDueTicks` starts a FRESH cursor at the current slot:
|
|
12
|
+
*
|
|
13
|
+
* const firstDue = cursor.lastEmittedSlot === undefined
|
|
14
|
+
* ? currentSlot
|
|
15
|
+
* : cursor.lastEmittedSlot + 1;
|
|
16
|
+
*
|
|
17
|
+
* That is right for a schedule's first ever poll — a new hourly schedule must
|
|
18
|
+
* not backfill from the epoch. But it means an in-memory-only cursor makes a
|
|
19
|
+
* restart SKIP every slot between shutdown and restart, silently. The dedupe
|
|
20
|
+
* key `(schedule_id, scheduled_for_ms)` makes re-delivery of a slot harmless,
|
|
21
|
+
* so a lost cursor cannot double-fire; nothing in the primitive protects
|
|
22
|
+
* against the skip. The runner is where that is either handled or lost, so it
|
|
23
|
+
* persists the cursor and reloads it on start.
|
|
24
|
+
*
|
|
25
|
+
* The distinction the file keeps: "no cursor on disk" means first run, start
|
|
26
|
+
* at the current slot. "A cursor on disk behind the grid" means catch up, and
|
|
27
|
+
* report anything past `maxCatchUp` as skipped. Conflating the two either
|
|
28
|
+
* backfills a new schedule from 1970 or silently drops a restart's arrears.
|
|
29
|
+
*
|
|
30
|
+
* ## Why a skip must reach the operator
|
|
31
|
+
*
|
|
32
|
+
* `skippedSlots` and `TickEmitError` exist because a skip that is only a
|
|
33
|
+
* return value is a skip that a `throw` can discard. `emitDueTicks` guarantees
|
|
34
|
+
* the accounting survives its own failure; this runner is the consumer that
|
|
35
|
+
* makes it visible. A skipped slot is real scheduled work that did not run.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
39
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
40
|
+
import { JournalClient } from '../journal-client.js';
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_MAX_CATCH_UP,
|
|
43
|
+
TickEmitError,
|
|
44
|
+
assertTickScheduleValid,
|
|
45
|
+
emitDueTicks,
|
|
46
|
+
scheduledForMs,
|
|
47
|
+
slotFor,
|
|
48
|
+
type TickCursor,
|
|
49
|
+
type TickEmitResult,
|
|
50
|
+
type TickSchedule,
|
|
51
|
+
} from '../tick-source.js';
|
|
52
|
+
import type { EventSubmitResult, HelloResult } from '../protocol.js';
|
|
53
|
+
import type { CliIo } from '../cli.js';
|
|
54
|
+
import { sleepInterruptible } from './interruptible-sleep.js';
|
|
55
|
+
|
|
56
|
+
/** How often to look for due slots when the caller does not say. */
|
|
57
|
+
const DEFAULT_POLL_INTERVAL_MS = 15_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Minimum client surface the runner uses. Concrete protocol return types
|
|
61
|
+
* rather than `unknown`, so a rename in the journal protocol fails to compile
|
|
62
|
+
* here instead of drifting silently past the interface — the same reason
|
|
63
|
+
* `HnMonitorClient` is shaped this way.
|
|
64
|
+
*/
|
|
65
|
+
export interface TickRunnerClient {
|
|
66
|
+
hello(client: string): Promise<HelloResult>;
|
|
67
|
+
eventSubmit(
|
|
68
|
+
spec: unknown,
|
|
69
|
+
event: { type: string; payload?: unknown; key?: string },
|
|
70
|
+
): Promise<EventSubmitResult>;
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where the runner's durable cursor lives, and what it holds. */
|
|
75
|
+
export interface TickRunnerState {
|
|
76
|
+
scheduleId: string;
|
|
77
|
+
/** Mirror of `TickCursor.lastEmittedSlot`. Absent before the first emit. */
|
|
78
|
+
lastEmittedSlot?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Wall clock of the last successful submit. Not used for scheduling — the
|
|
81
|
+
* grid owns that — but it is what makes a dead runner detectable, so
|
|
82
|
+
* `staleAfterMs` means something end to end rather than only inside the
|
|
83
|
+
* kernel's own sweep.
|
|
84
|
+
*/
|
|
85
|
+
lastEmittedAtMs?: number;
|
|
86
|
+
/** Every slot ever passed over, oldest first. Append-only. */
|
|
87
|
+
skippedSlots: number[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TickRunnerArgsBase {
|
|
91
|
+
/** Data directory containing `relayflowd.sock`. Required. */
|
|
92
|
+
dataDir: string;
|
|
93
|
+
/** Absolute path to the canonical flow spec JSON. Required. */
|
|
94
|
+
specPath: string;
|
|
95
|
+
/** The grid. Validated at declaration, before anything connects. */
|
|
96
|
+
schedule: TickSchedule;
|
|
97
|
+
/** How often to look for due slots. Default 15000ms. */
|
|
98
|
+
pollIntervalMs?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Where to persist the cursor. Defaults to
|
|
101
|
+
* `<dataDir>/tick-state/<scheduleId>.json`.
|
|
102
|
+
*/
|
|
103
|
+
statePath?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Cap on poll iterations. Undefined = unbounded (production). 0 means
|
|
106
|
+
* "connect, poll zero times, drain, exit 0" — used by tests that only need
|
|
107
|
+
* the setup and teardown paths.
|
|
108
|
+
*/
|
|
109
|
+
maxPolls?: number;
|
|
110
|
+
/** AbortSignal for external cancellation (tests, SIGINT wiring). */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Injectable clock so tests can place `now` on the grid deliberately. */
|
|
113
|
+
now?: () => number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Test injection surface. `connectClient` is supplied alone here — unlike
|
|
118
|
+
* hn-monitor, this runner attaches no worker, so there is no pairing to
|
|
119
|
+
* enforce: a tick is submitted through `event.submit` and the kernel dispatches
|
|
120
|
+
* to whatever worker the flow's trigger names.
|
|
121
|
+
*/
|
|
122
|
+
export interface TickRunnerArgs extends TickRunnerArgsBase {
|
|
123
|
+
/**
|
|
124
|
+
* Async, because a real `JournalClient` needs `connect()` before `hello()`.
|
|
125
|
+
* A synchronous injection surface hid that: the unit tests' fake client has
|
|
126
|
+
* no transport, so it connected vacuously and the missing `connect()` only
|
|
127
|
+
* surfaced against a live daemon with `journal client: not connected
|
|
128
|
+
* (hello)`. The default path below owns connect+hello so no caller can
|
|
129
|
+
* forget half of it.
|
|
130
|
+
*/
|
|
131
|
+
connectClient?: (socketPath: string) => Promise<TickRunnerClient>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Connect and handshake. Both steps, or neither. */
|
|
135
|
+
async function defaultConnectClient(socketPath: string): Promise<TickRunnerClient> {
|
|
136
|
+
const client = new JournalClient(socketPath);
|
|
137
|
+
await client.connect();
|
|
138
|
+
await client.hello('flows-tick-runner');
|
|
139
|
+
return client;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Absolute path to the state file for one schedule. */
|
|
143
|
+
export function tickStatePath(dataDir: string, scheduleId: string): string {
|
|
144
|
+
return join(dataDir, 'tick-state', `${encodeURIComponent(scheduleId)}.json`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Load the durable cursor, distinguishing "no state" from "state behind the
|
|
149
|
+
* grid". A missing file is a first run and must start at the current slot; a
|
|
150
|
+
* present file must be honoured however far behind it is, so the arrears are
|
|
151
|
+
* either caught up or reported.
|
|
152
|
+
*
|
|
153
|
+
* A malformed or foreign-schedule state file is a hard error rather than a
|
|
154
|
+
* silent reset: resetting would convert an operator's corrupted file into a
|
|
155
|
+
* silent skip of everything since the last good emit, which is the failure
|
|
156
|
+
* this runner exists to prevent.
|
|
157
|
+
*/
|
|
158
|
+
export async function loadTickState(
|
|
159
|
+
path: string,
|
|
160
|
+
scheduleId: string,
|
|
161
|
+
): Promise<TickRunnerState> {
|
|
162
|
+
let raw: string;
|
|
163
|
+
try {
|
|
164
|
+
raw = await readFile(path, 'utf8');
|
|
165
|
+
} catch (cause) {
|
|
166
|
+
if ((cause as { code?: string }).code === 'ENOENT') {
|
|
167
|
+
return { scheduleId, skippedSlots: [] };
|
|
168
|
+
}
|
|
169
|
+
throw cause;
|
|
170
|
+
}
|
|
171
|
+
let parsed: unknown;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(raw);
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
throw new Error(`tick state at ${path} is not valid JSON`, { cause });
|
|
176
|
+
}
|
|
177
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
178
|
+
throw new Error(`tick state at ${path} is not an object`);
|
|
179
|
+
}
|
|
180
|
+
const state = parsed as Partial<TickRunnerState>;
|
|
181
|
+
if (state.scheduleId !== scheduleId) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`tick state at ${path} belongs to schedule ${String(state.scheduleId)}, not ${scheduleId}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (state.lastEmittedSlot !== undefined && !Number.isInteger(state.lastEmittedSlot)) {
|
|
187
|
+
throw new Error(`tick state at ${path} has a non-integer lastEmittedSlot`);
|
|
188
|
+
}
|
|
189
|
+
// Fail closed rather than coerce. `Array.isArray(...) ? ... : []` silently
|
|
190
|
+
// turned a malformed value into "no slots were skipped" — which is the exact
|
|
191
|
+
// claim this runner exists to make trustworthy. A state file that cannot say
|
|
192
|
+
// what it missed must stop the runner, not quietly report that it missed
|
|
193
|
+
// nothing.
|
|
194
|
+
if (state.skippedSlots !== undefined && !Array.isArray(state.skippedSlots)) {
|
|
195
|
+
throw new Error(`tick state at ${path} has a non-array skippedSlots`);
|
|
196
|
+
}
|
|
197
|
+
if (state.skippedSlots?.some((slot) => !Number.isInteger(slot))) {
|
|
198
|
+
throw new Error(`tick state at ${path} has a non-integer entry in skippedSlots`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
scheduleId,
|
|
202
|
+
...(state.lastEmittedSlot === undefined ? {} : { lastEmittedSlot: state.lastEmittedSlot }),
|
|
203
|
+
...(state.lastEmittedAtMs === undefined ? {} : { lastEmittedAtMs: state.lastEmittedAtMs }),
|
|
204
|
+
skippedSlots: state.skippedSlots ?? [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Persist the cursor. Written to a temp file and renamed, so a crash mid-write
|
|
210
|
+
* leaves the previous good state rather than a truncated file — a truncated
|
|
211
|
+
* file would be a hard error on next start, which is safe but needlessly
|
|
212
|
+
* blocks a runner that had a perfectly good cursor a moment earlier.
|
|
213
|
+
*/
|
|
214
|
+
export async function saveTickState(path: string, state: TickRunnerState): Promise<void> {
|
|
215
|
+
await mkdir(dirname(path), { recursive: true });
|
|
216
|
+
const temp = `${path}.tmp`;
|
|
217
|
+
await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
218
|
+
const { rename } = await import('node:fs/promises');
|
|
219
|
+
await rename(temp, path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One poll's accounting, for logging and for tests to assert on. */
|
|
223
|
+
export interface TickPollReport {
|
|
224
|
+
emittedSlots: number[];
|
|
225
|
+
skippedSlots: number[];
|
|
226
|
+
/** Set when the poll threw. The partial accounting is still present. */
|
|
227
|
+
failure?: unknown;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run `flows tick start`. Returns 0 on clean shutdown, 1 on a fatal error.
|
|
232
|
+
*
|
|
233
|
+
* Fatal means: the schedule is invalid, the state file is unreadable or
|
|
234
|
+
* belongs to another schedule, the journal cannot be reached, or a submit
|
|
235
|
+
* failed. A submit failure is fatal by design — `emitDueTicks` leaves the slot
|
|
236
|
+
* due, so retrying is the next poll's job, but a runner that swallows journal
|
|
237
|
+
* failures and keeps looping is the silent-zero this is meant to prevent.
|
|
238
|
+
*/
|
|
239
|
+
export async function runTickRunner(args: TickRunnerArgs, io: CliIo): Promise<number> {
|
|
240
|
+
const now = args.now ?? Date.now;
|
|
241
|
+
const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
242
|
+
|
|
243
|
+
// Refuse a bad grid BEFORE connecting. A runner that attaches and only then
|
|
244
|
+
// discovers `--interval-ms` was 1.5 has already told the operator it started.
|
|
245
|
+
try {
|
|
246
|
+
assertTickScheduleValid(args.schedule, now());
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
io.stderr(`REFUSED [invalid_schedule] ${(cause as Error).message}`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
if (!Number.isInteger(pollIntervalMs) || pollIntervalMs <= 0) {
|
|
252
|
+
io.stderr('REFUSED [invalid_schedule] pollIntervalMs must be a positive integer');
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const specPath = isAbsolute(args.specPath) ? args.specPath : resolve(args.specPath);
|
|
257
|
+
const statePath = args.statePath ?? tickStatePath(args.dataDir, args.schedule.scheduleId);
|
|
258
|
+
|
|
259
|
+
let spec: unknown;
|
|
260
|
+
let state: TickRunnerState;
|
|
261
|
+
try {
|
|
262
|
+
spec = JSON.parse(await readFile(specPath, 'utf8'));
|
|
263
|
+
state = await loadTickState(statePath, args.schedule.scheduleId);
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
io.stderr(`REFUSED [invalid_state] ${(cause as Error).message}`);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const resuming = state.lastEmittedSlot !== undefined;
|
|
270
|
+
const cursor: TickCursor = resuming ? { lastEmittedSlot: state.lastEmittedSlot } : {};
|
|
271
|
+
|
|
272
|
+
const socketPath = join(args.dataDir, 'relayflowd.sock');
|
|
273
|
+
let client: TickRunnerClient;
|
|
274
|
+
try {
|
|
275
|
+
client = args.connectClient
|
|
276
|
+
? await args.connectClient(socketPath)
|
|
277
|
+
: await defaultConnectClient(socketPath);
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
io.stderr(`TICK_RUNNER_FAILED ${(cause as Error).constructor.name}: ${(cause as Error).message}`);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let exitCode = 0;
|
|
284
|
+
try {
|
|
285
|
+
const startSlot = slotFor(args.schedule, now());
|
|
286
|
+
io.stdout(
|
|
287
|
+
resuming
|
|
288
|
+
? `TICK_RUNNER schedule=${args.schedule.scheduleId} resuming from slot ${String(state.lastEmittedSlot)}; current slot ${startSlot}`
|
|
289
|
+
: `TICK_RUNNER schedule=${args.schedule.scheduleId} first run; starting at slot ${startSlot}`,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
let polls = 0;
|
|
293
|
+
while (!(args.signal?.aborted ?? false)) {
|
|
294
|
+
if (args.maxPolls !== undefined && polls >= args.maxPolls) break;
|
|
295
|
+
polls += 1;
|
|
296
|
+
|
|
297
|
+
const report = await pollOnce(spec, client, args.schedule, cursor, now());
|
|
298
|
+
|
|
299
|
+
// Persist before reporting. The cursor is the thing a restart depends
|
|
300
|
+
// on; losing it costs slots, whereas losing a log line costs a message.
|
|
301
|
+
if (report.emittedSlots.length > 0) {
|
|
302
|
+
state.lastEmittedSlot = cursor.lastEmittedSlot;
|
|
303
|
+
state.lastEmittedAtMs = now();
|
|
304
|
+
}
|
|
305
|
+
if (report.skippedSlots.length > 0) {
|
|
306
|
+
state.skippedSlots.push(...report.skippedSlots);
|
|
307
|
+
}
|
|
308
|
+
if (report.emittedSlots.length > 0 || report.skippedSlots.length > 0) {
|
|
309
|
+
await saveTickState(statePath, state);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for (const slot of report.skippedSlots) {
|
|
313
|
+
// Loud, per slot, with the instant it stood for. A count would let a
|
|
314
|
+
// reader skim past "3 skipped"; an instant is a thing an operator can
|
|
315
|
+
// go and look for in the journal and fail to find.
|
|
316
|
+
io.stderr(
|
|
317
|
+
`TICK_SKIPPED schedule=${args.schedule.scheduleId} slot=${slot} scheduled_for_ms=${scheduledForMs(args.schedule, slot)} — past maxCatchUp=${args.schedule.maxCatchUp ?? DEFAULT_MAX_CATCH_UP}; this scheduled work did NOT run`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
for (const slot of report.emittedSlots) {
|
|
321
|
+
io.stdout(
|
|
322
|
+
`TICK_EMITTED schedule=${args.schedule.scheduleId} slot=${slot} scheduled_for_ms=${scheduledForMs(args.schedule, slot)}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (report.failure !== undefined) {
|
|
327
|
+
const failure = report.failure;
|
|
328
|
+
const name = failure instanceof Error ? failure.constructor.name : typeof failure;
|
|
329
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
330
|
+
io.stderr(`TICK_RUNNER_FAILED ${name}: ${message}`);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (args.signal?.aborted ?? false) break;
|
|
335
|
+
if (args.maxPolls !== undefined && polls >= args.maxPolls) break;
|
|
336
|
+
await sleepInterruptible(pollIntervalMs, args.signal);
|
|
337
|
+
}
|
|
338
|
+
io.stdout(`TICK_RUNNER_STOPPED schedule=${args.schedule.scheduleId} polls=${polls}`);
|
|
339
|
+
} catch (cause) {
|
|
340
|
+
const name = cause instanceof Error ? cause.constructor.name : typeof cause;
|
|
341
|
+
io.stderr(`TICK_RUNNER_FAILED ${name}: ${(cause as Error).message}`);
|
|
342
|
+
exitCode = 1;
|
|
343
|
+
} finally {
|
|
344
|
+
client.close();
|
|
345
|
+
}
|
|
346
|
+
return exitCode;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* One poll. Normalises the two shapes `emitDueTicks` can produce — a result,
|
|
351
|
+
* or a `TickEmitError` carrying the partial result — into one report, so the
|
|
352
|
+
* caller has exactly one accounting path and cannot handle the success case
|
|
353
|
+
* and forget the failure case.
|
|
354
|
+
*/
|
|
355
|
+
async function pollOnce(
|
|
356
|
+
spec: unknown,
|
|
357
|
+
sink: TickRunnerClient,
|
|
358
|
+
schedule: TickSchedule,
|
|
359
|
+
cursor: TickCursor,
|
|
360
|
+
nowMs: number,
|
|
361
|
+
): Promise<TickPollReport> {
|
|
362
|
+
try {
|
|
363
|
+
const result: TickEmitResult = await emitDueTicks(spec, sink, { schedule, cursor, nowMs });
|
|
364
|
+
return { emittedSlots: result.emittedSlots, skippedSlots: result.skippedSlots };
|
|
365
|
+
} catch (cause) {
|
|
366
|
+
if (cause instanceof TickEmitError) {
|
|
367
|
+
// The partial accounting travelled with the failure. Surface it, then
|
|
368
|
+
// let the caller treat the failure as fatal — the unemitted slots are
|
|
369
|
+
// still due, because `emitDueTicks` only advances on success.
|
|
370
|
+
return {
|
|
371
|
+
emittedSlots: cause.emittedSlots,
|
|
372
|
+
skippedSlots: cause.skippedSlots,
|
|
373
|
+
failure: cause.cause ?? cause,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { emittedSlots: [], skippedSlots: [], failure: cause };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1';
|
|
4
|
+
|
|
5
|
+
export interface CliInvocation {
|
|
6
|
+
args: string[];
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
/** Set only for wrapper readiness probes; raw providers receive a model flag. */
|
|
9
|
+
modelEnv?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CliAdapterIdentification {
|
|
13
|
+
invocation: CliInvocation;
|
|
14
|
+
expectedStdout?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1';
|
|
18
|
+
export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1';
|
|
19
|
+
export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute';
|
|
20
|
+
|
|
21
|
+
const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';
|
|
22
|
+
|
|
23
|
+
/** Select a closed adapter from the resolved executable's basename. */
|
|
24
|
+
export function cliAdapterKind(executable: string): CliAdapterKind {
|
|
25
|
+
const name = basename(executable).replace(/\.exe$/i, '');
|
|
26
|
+
if (name === 'claude') return 'claude';
|
|
27
|
+
if (name === 'codex') return 'codex';
|
|
28
|
+
return 'relayflows-wrapper-v1';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Prove the adapter command shape before classifying an auth failure. */
|
|
32
|
+
export function adapterIdentification(kind: CliAdapterKind): CliAdapterIdentification {
|
|
33
|
+
if (kind === 'claude') {
|
|
34
|
+
return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } };
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'codex') {
|
|
37
|
+
return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } };
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 },
|
|
41
|
+
expectedStdout: WRAPPER_IDENTIFY_TOKEN,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function authenticationProbe(kind: CliAdapterKind): CliInvocation {
|
|
46
|
+
if (kind === 'codex') return { args: ['login', 'status'], timeoutMs: 10_000 };
|
|
47
|
+
return { args: ['auth', 'status'], timeoutMs: 10_000 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A provider model probe is a real, noninteractive model round trip. The
|
|
52
|
+
* wrapper protocol keeps its established auth-status shape and receives the
|
|
53
|
+
* exact model through its explicitly identified environment contract.
|
|
54
|
+
*/
|
|
55
|
+
export function modelReadinessProbe(kind: CliAdapterKind, model: string): CliInvocation {
|
|
56
|
+
if (kind === 'claude') {
|
|
57
|
+
return {
|
|
58
|
+
args: [
|
|
59
|
+
'-p', '--model', model, '--tools', '', '--no-session-persistence',
|
|
60
|
+
MODEL_PROBE_PROMPT,
|
|
61
|
+
],
|
|
62
|
+
timeoutMs: 60_000,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (kind === 'codex') {
|
|
66
|
+
return {
|
|
67
|
+
args: [
|
|
68
|
+
'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check',
|
|
69
|
+
'--model', model, MODEL_PROBE_PROMPT,
|
|
70
|
+
],
|
|
71
|
+
timeoutMs: 60_000,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
args: ['auth', 'status'],
|
|
76
|
+
timeoutMs: 60_000,
|
|
77
|
+
modelEnv: model,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build the actual worker argv; this is shared contract, not probe-only lore. */
|
|
82
|
+
export function agentExecution(
|
|
83
|
+
kind: CliAdapterKind,
|
|
84
|
+
instruction: string,
|
|
85
|
+
model?: string,
|
|
86
|
+
): CliInvocation {
|
|
87
|
+
if (kind === 'claude') {
|
|
88
|
+
return {
|
|
89
|
+
args: ['-p', ...(model === undefined ? [] : ['--model', model]), instruction],
|
|
90
|
+
timeoutMs: 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (kind === 'codex') {
|
|
94
|
+
return {
|
|
95
|
+
args: [
|
|
96
|
+
'exec', '--ephemeral', '--skip-git-repo-check',
|
|
97
|
+
...(model === undefined ? [] : ['--model', model]),
|
|
98
|
+
instruction,
|
|
99
|
+
],
|
|
100
|
+
timeoutMs: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
throw new Error('custom wrapper execution requires the runAgentCli same-process session');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function displayInvocation(cli: string, invocation: CliInvocation): string {
|
|
107
|
+
const command = [cli, ...invocation.args].map(shellDisplayWord).join(' ');
|
|
108
|
+
return invocation.modelEnv === undefined
|
|
109
|
+
? command
|
|
110
|
+
: `RELAYFLOW_MODEL=${shellDisplayWord(invocation.modelEnv)} ${command}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shellDisplayWord(word: string): string {
|
|
114
|
+
return /^[A-Za-z0-9_./:-]+$/.test(word) ? word : JSON.stringify(word);
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runCli } from './cli.js';
|
|
4
|
+
|
|
5
|
+
async function main(): Promise<void> {
|
|
6
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
void main().catch((error: unknown) => {
|
|
10
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
});
|