@intx/workflow-host 0.2.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/LICENSE +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- package/package.json +35 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate";
|
|
2
|
+
/**
|
|
3
|
+
* Substrate-shape envelope for the workflow-event blob committed to
|
|
4
|
+
* `runs/<runId>/events/<seq>.json`. The validator covers the two
|
|
5
|
+
* event types the scheduler reads (TimerSet, TimerFired) and the
|
|
6
|
+
* single type it writes (TimerFired). Non-timer blobs at the same
|
|
7
|
+
* path prefix do not match this validator and are skipped silently
|
|
8
|
+
* by the recovery walk. `seq` mirrors the integer in the filename,
|
|
9
|
+
* matching the workflow-run kind handler's `EventEnvelope` contract.
|
|
10
|
+
*/
|
|
11
|
+
export declare const TimerEventEnvelope: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
12
|
+
seq: number;
|
|
13
|
+
type: "TimerSet" | "TimerFired";
|
|
14
|
+
data: {
|
|
15
|
+
timerId: string;
|
|
16
|
+
fireAt?: string;
|
|
17
|
+
stepId?: string | null;
|
|
18
|
+
cron?: string | null;
|
|
19
|
+
};
|
|
20
|
+
}, {}>;
|
|
21
|
+
export type TimerEventEnvelope = typeof TimerEventEnvelope.infer;
|
|
22
|
+
export type SchedulerOpts = {
|
|
23
|
+
/**
|
|
24
|
+
* Substrate handle the scheduler reads from and writes to. The
|
|
25
|
+
* caller wires this against the workflow-run kind handler's
|
|
26
|
+
* registered substrate -- the scheduler does not care about the
|
|
27
|
+
* kind discriminator, but the handler's `validatePush` must accept
|
|
28
|
+
* the `runs/<runId>/events/<seq>.json` writes the scheduler emits.
|
|
29
|
+
*/
|
|
30
|
+
repoStore: RepoStore;
|
|
31
|
+
/**
|
|
32
|
+
* Principal the scheduler presents to the substrate. The substrate
|
|
33
|
+
* gates every operation behind `authorize`; the scheduler's
|
|
34
|
+
* principal must be granted `writeTree` and `resolveRef` against
|
|
35
|
+
* the workflow-run repos it services.
|
|
36
|
+
*/
|
|
37
|
+
principal: Principal;
|
|
38
|
+
/**
|
|
39
|
+
* Callback that enumerates the active deployment workflow-run
|
|
40
|
+
* repos the scheduler is responsible for. The scheduler invokes
|
|
41
|
+
* this at `start()` time to seed its recovery walk and again on
|
|
42
|
+
* every `TimerFired` commit to attribute the runId to its owning
|
|
43
|
+
* deployment.
|
|
44
|
+
*/
|
|
45
|
+
listActiveDeployments: () => Promise<readonly RepoId[]> | readonly RepoId[];
|
|
46
|
+
/**
|
|
47
|
+
* Events ref to tail. The workflow-run repo layout pins all
|
|
48
|
+
* `runs/<runId>/events/` blobs under a single moving ref. Callers
|
|
49
|
+
* typically supply `"refs/heads/main"`.
|
|
50
|
+
*/
|
|
51
|
+
ref: string;
|
|
52
|
+
/**
|
|
53
|
+
* Clock used to compute delay-from-fireAt for queueing setTimeout
|
|
54
|
+
* callbacks and to skip past-due cron entries on recovery.
|
|
55
|
+
*/
|
|
56
|
+
clock: () => Date;
|
|
57
|
+
};
|
|
58
|
+
export type SchedulerHandle = {
|
|
59
|
+
/**
|
|
60
|
+
* Run start-time recovery against every active deployment. After
|
|
61
|
+
* the recovery walk completes, every unfired one-shot timer is
|
|
62
|
+
* queued and every missed cron tick has been skipped per the spec.
|
|
63
|
+
* Idempotent: a second `start()` is a no-op while the first is
|
|
64
|
+
* still pending.
|
|
65
|
+
*/
|
|
66
|
+
start(): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Tear down every queued timer. Idempotent. Outstanding
|
|
69
|
+
* `setTimeout` handles are cancelled. After `stop()` the scheduler
|
|
70
|
+
* holds no host resources.
|
|
71
|
+
*/
|
|
72
|
+
stop(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Cancel any queued timer matching `(runId, timerId)`. The
|
|
75
|
+
* runtime-shaped `Scheduler.scheduleIn` returns a disposer the
|
|
76
|
+
* runtime body invokes when the awaiting site settles on a sibling
|
|
77
|
+
* event before the timer's deadline; the adapter routes that
|
|
78
|
+
* disposer here so the host scheduler does not commit a
|
|
79
|
+
* `TimerFired` after the runtime has moved on. Idempotent: a call
|
|
80
|
+
* for an unknown key is a no-op.
|
|
81
|
+
*/
|
|
82
|
+
cancelQueued(runId: string, timerId: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* Test-visible view of currently-queued timers. The shape is
|
|
85
|
+
* intentionally narrow: the runtime body never inspects the
|
|
86
|
+
* scheduler's queue; tests assert on it directly.
|
|
87
|
+
*/
|
|
88
|
+
queuedTimers(): readonly {
|
|
89
|
+
runId: string;
|
|
90
|
+
timerId: string;
|
|
91
|
+
fireAtMs: number;
|
|
92
|
+
}[];
|
|
93
|
+
};
|
|
94
|
+
export declare function createWorkflowHostScheduler(opts: SchedulerOpts): SchedulerHandle;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Production workflow-host scheduler (Seam 1, event-sourced wait).
|
|
2
|
+
//
|
|
3
|
+
// The scheduler is a singleton per host process. It is the single
|
|
4
|
+
// writer of `TimerFired` to every active deployment's workflow-run
|
|
5
|
+
// log. The runtime body's `waitForTimer` helper subscribes to the
|
|
6
|
+
// run's log via `subscribeKind` and resolves when this scheduler
|
|
7
|
+
// commits the matching `TimerFired` event. No other component in
|
|
8
|
+
// the host -- workflow-process child, supervisor, sidecar handler --
|
|
9
|
+
// commits `TimerFired`. This single-writer invariant is what makes
|
|
10
|
+
// the recovery story work: at startup the scheduler walks the
|
|
11
|
+
// persisted log for unfired `TimerSet` events and re-queues them,
|
|
12
|
+
// confident that any `TimerFired` it does not find was never
|
|
13
|
+
// committed and is its responsibility to commit now.
|
|
14
|
+
//
|
|
15
|
+
// Recovery semantics:
|
|
16
|
+
// - One-shot timers (`TimerSet` without a `cron` discriminator) on
|
|
17
|
+
// resume are queued with their stored `fireAt`. If `fireAt` is
|
|
18
|
+
// already in the past the scheduler fires immediately so the
|
|
19
|
+
// paired `TimerFired` lands and the awaiting runtime body
|
|
20
|
+
// unblocks. The runtime is responsible for its own jitter
|
|
21
|
+
// tolerance.
|
|
22
|
+
// - Cron-style timers whose `fireAt` is in the past on resume are
|
|
23
|
+
// SKIPPED -- per the spec, missed cron ticks do not replay. The
|
|
24
|
+
// scheduler waits for the next future tick to be committed and
|
|
25
|
+
// queues that one.
|
|
26
|
+
//
|
|
27
|
+
// The scheduler reads workflow-event blobs at the canonical layout
|
|
28
|
+
// `runs/<runId>/events/<seq>.json` and writes a fresh blob at the
|
|
29
|
+
// next-seq slot for each `TimerFired` commit. The blob envelope
|
|
30
|
+
// carries `{ seq, type, data }` at the top level: `seq` is the
|
|
31
|
+
// integer that also appears in the filename, `type` is the
|
|
32
|
+
// `subscribeKind` discriminator the scheduler narrows on, and `data`
|
|
33
|
+
// carries the timer payload. The workflow-run kind handler's
|
|
34
|
+
// `validatePush` enforces that every event blob's body `seq` matches
|
|
35
|
+
// the filename's seq, so the scheduler mints the next seq inside the
|
|
36
|
+
// `writeTreePreservingPrefix` merge step and writes both into the
|
|
37
|
+
// envelope and the filename.
|
|
38
|
+
import { type } from "arktype";
|
|
39
|
+
import { subscribeKind } from "@intx/hub-sessions/substrate";
|
|
40
|
+
/**
|
|
41
|
+
* Substrate-shape envelope for the workflow-event blob committed to
|
|
42
|
+
* `runs/<runId>/events/<seq>.json`. The validator covers the two
|
|
43
|
+
* event types the scheduler reads (TimerSet, TimerFired) and the
|
|
44
|
+
* single type it writes (TimerFired). Non-timer blobs at the same
|
|
45
|
+
* path prefix do not match this validator and are skipped silently
|
|
46
|
+
* by the recovery walk. `seq` mirrors the integer in the filename,
|
|
47
|
+
* matching the workflow-run kind handler's `EventEnvelope` contract.
|
|
48
|
+
*/
|
|
49
|
+
export const TimerEventEnvelope = type({
|
|
50
|
+
seq: "number >= 0",
|
|
51
|
+
type: "'TimerSet' | 'TimerFired'",
|
|
52
|
+
data: {
|
|
53
|
+
timerId: "string",
|
|
54
|
+
"fireAt?": "string",
|
|
55
|
+
"stepId?": "string | null",
|
|
56
|
+
"cron?": "string | null",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
export function createWorkflowHostScheduler(opts) {
|
|
60
|
+
const queues = new Map();
|
|
61
|
+
const liveSubscriptions = [];
|
|
62
|
+
let started = false;
|
|
63
|
+
let stopped = false;
|
|
64
|
+
function queueKey(runId, timerId) {
|
|
65
|
+
return `${runId} ${timerId}`;
|
|
66
|
+
}
|
|
67
|
+
async function fireTimer(runId, timerId) {
|
|
68
|
+
const key = queueKey(runId, timerId);
|
|
69
|
+
const entry = queues.get(key);
|
|
70
|
+
if (entry === undefined)
|
|
71
|
+
return;
|
|
72
|
+
queues.delete(key);
|
|
73
|
+
if (stopped)
|
|
74
|
+
return;
|
|
75
|
+
await commitTimerFired(opts, runId, timerId);
|
|
76
|
+
}
|
|
77
|
+
function enqueue(repoId, runId, timerId, fireAtMs, cron) {
|
|
78
|
+
if (stopped)
|
|
79
|
+
return;
|
|
80
|
+
const key = queueKey(runId, timerId);
|
|
81
|
+
if (queues.has(key))
|
|
82
|
+
return; // idempotent
|
|
83
|
+
const delayMs = Math.max(0, fireAtMs - opts.clock().getTime());
|
|
84
|
+
const timeout = setTimeout(() => {
|
|
85
|
+
void fireTimer(runId, timerId).catch((cause) => {
|
|
86
|
+
// The scheduler's commit failed. Surface as unhandled so
|
|
87
|
+
// operators see it; the runtime body's awaiter will hang
|
|
88
|
+
// until restart triggers recovery.
|
|
89
|
+
throw cause instanceof Error
|
|
90
|
+
? cause
|
|
91
|
+
: new Error(`scheduler ${String(repoId.id)}/${runId}/${timerId} commit failed: ${String(cause)}`);
|
|
92
|
+
});
|
|
93
|
+
}, delayMs);
|
|
94
|
+
queues.set(key, { runId, timerId, fireAtMs, timeout, cron });
|
|
95
|
+
}
|
|
96
|
+
function startLiveSubscription(repoId) {
|
|
97
|
+
const abort = new AbortController();
|
|
98
|
+
const done = (async () => {
|
|
99
|
+
const iter = subscribeKind(opts.repoStore, opts.principal, repoId, opts.ref, TimerEventEnvelope, {
|
|
100
|
+
signal: abort.signal,
|
|
101
|
+
from: "head",
|
|
102
|
+
kinds: ["TimerSet"],
|
|
103
|
+
});
|
|
104
|
+
for await (const entry of iter) {
|
|
105
|
+
if (stopped)
|
|
106
|
+
break;
|
|
107
|
+
if (entry.event.type !== "TimerSet")
|
|
108
|
+
continue;
|
|
109
|
+
const fireAt = entry.event.data.fireAt;
|
|
110
|
+
if (fireAt === undefined) {
|
|
111
|
+
throw new Error(`scheduler live ingest: TimerSet in ${String(repoId.id)} run ${entry.runId} timer ${entry.event.data.timerId} missing fireAt`);
|
|
112
|
+
}
|
|
113
|
+
const fireAtMs = Date.parse(fireAt);
|
|
114
|
+
if (Number.isNaN(fireAtMs)) {
|
|
115
|
+
throw new Error(`scheduler live ingest: TimerSet in ${String(repoId.id)} run ${entry.runId} timer ${entry.event.data.timerId} fireAt unparseable: ${fireAt}`);
|
|
116
|
+
}
|
|
117
|
+
const cron = entry.event.data.cron !== undefined && entry.event.data.cron !== null;
|
|
118
|
+
if (cron && fireAtMs < opts.clock().getTime()) {
|
|
119
|
+
// Same missed-cron-tick spec as recovery: a cron TimerSet
|
|
120
|
+
// whose fireAt is in the past on arrival is dropped.
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
enqueue(repoId, entry.runId, entry.event.data.timerId, fireAtMs, cron);
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
liveSubscriptions.push({ abort, done });
|
|
127
|
+
}
|
|
128
|
+
async function recoverDeployment(repoId) {
|
|
129
|
+
const events = await readAllEvents(opts, repoId);
|
|
130
|
+
// Build per-(runId, timerId) ledger: a TimerSet without a
|
|
131
|
+
// matching TimerFired is unfired. The walk is order-insensitive
|
|
132
|
+
// because the second pass deletes matched entries.
|
|
133
|
+
const unfired = new Map();
|
|
134
|
+
for (const e of events) {
|
|
135
|
+
if (e.envelope.type === "TimerSet") {
|
|
136
|
+
const fireAt = e.envelope.data.fireAt;
|
|
137
|
+
if (fireAt === undefined) {
|
|
138
|
+
throw new Error(`scheduler recovery: TimerSet in ${String(repoId.id)} run ${e.runId} timer ${e.envelope.data.timerId} missing fireAt`);
|
|
139
|
+
}
|
|
140
|
+
const fireAtMs = Date.parse(fireAt);
|
|
141
|
+
if (Number.isNaN(fireAtMs)) {
|
|
142
|
+
throw new Error(`scheduler recovery: TimerSet in ${String(repoId.id)} run ${e.runId} timer ${e.envelope.data.timerId} fireAt unparseable: ${fireAt}`);
|
|
143
|
+
}
|
|
144
|
+
const cron = e.envelope.data.cron !== undefined && e.envelope.data.cron !== null;
|
|
145
|
+
unfired.set(`${e.runId} ${e.envelope.data.timerId}`, {
|
|
146
|
+
runId: e.runId,
|
|
147
|
+
timerId: e.envelope.data.timerId,
|
|
148
|
+
fireAtMs,
|
|
149
|
+
cron,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
unfired.delete(`${e.runId} ${e.envelope.data.timerId}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const now = opts.clock().getTime();
|
|
157
|
+
for (const entry of unfired.values()) {
|
|
158
|
+
if (entry.cron && entry.fireAtMs < now) {
|
|
159
|
+
// Spec: missed cron ticks are skipped on resume. The next
|
|
160
|
+
// cron tick will be committed by whoever owns the cron
|
|
161
|
+
// emitter; the scheduler simply does not replay this one.
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
enqueue(repoId, entry.runId, entry.timerId, entry.fireAtMs, entry.cron);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
async start() {
|
|
169
|
+
if (started)
|
|
170
|
+
return;
|
|
171
|
+
started = true;
|
|
172
|
+
const repoIds = await opts.listActiveDeployments();
|
|
173
|
+
for (const repoId of repoIds) {
|
|
174
|
+
await recoverDeployment(repoId);
|
|
175
|
+
}
|
|
176
|
+
// Live `TimerSet` ingestion. After the recovery walk, open a
|
|
177
|
+
// per-deployment `subscribeKind` loop against the workflow-run
|
|
178
|
+
// events ref with `from: "head"` and `kinds: ["TimerSet"]`. Each
|
|
179
|
+
// yielded entry carries its owning runId (the substrate's
|
|
180
|
+
// `SubscribeKindEntry` surfaces the path-derived runId
|
|
181
|
+
// alongside the workflow-event seq), and the TimerSet payload
|
|
182
|
+
// carries the wall-clock `fireAt` plus the optional `cron`
|
|
183
|
+
// discriminator. `enqueue` is idempotent on `(runId, timerId)`,
|
|
184
|
+
// so a TimerSet that the recovery walk already queued (i.e.
|
|
185
|
+
// committed before subscribe could install its watcher) is
|
|
186
|
+
// safely re-yielded without duplicating the queue entry.
|
|
187
|
+
// `TimerFired` is not in the `kinds` filter: the scheduler
|
|
188
|
+
// commits `TimerFired` itself and does not need to ingest its
|
|
189
|
+
// own writes.
|
|
190
|
+
for (const repoId of repoIds) {
|
|
191
|
+
startLiveSubscription(repoId);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
async stop() {
|
|
195
|
+
if (stopped)
|
|
196
|
+
return;
|
|
197
|
+
stopped = true;
|
|
198
|
+
for (const t of queues.values())
|
|
199
|
+
clearTimeout(t.timeout);
|
|
200
|
+
queues.clear();
|
|
201
|
+
for (const sub of liveSubscriptions.splice(0)) {
|
|
202
|
+
sub.abort.abort();
|
|
203
|
+
await sub.done.catch(() => {
|
|
204
|
+
/* swallow aborted-iterator surface */
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
cancelQueued(runId, timerId) {
|
|
209
|
+
const key = queueKey(runId, timerId);
|
|
210
|
+
const entry = queues.get(key);
|
|
211
|
+
if (entry === undefined)
|
|
212
|
+
return;
|
|
213
|
+
clearTimeout(entry.timeout);
|
|
214
|
+
queues.delete(key);
|
|
215
|
+
},
|
|
216
|
+
queuedTimers() {
|
|
217
|
+
return [...queues.values()].map((t) => ({
|
|
218
|
+
runId: t.runId,
|
|
219
|
+
timerId: t.timerId,
|
|
220
|
+
fireAtMs: t.fireAtMs,
|
|
221
|
+
}));
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Read every timer-event blob across every run under the given
|
|
227
|
+
* workflow-run repo. The recovery walk reads blobs from the
|
|
228
|
+
* substrate's on-disk working tree directly via `enumerateEventBlobs`:
|
|
229
|
+
* `subscribeKind` is a diff-shaped iterator over new commits, not a
|
|
230
|
+
* "list everything at HEAD" primitive, so the startup ledger needs a
|
|
231
|
+
* path-aware enumeration of the current ref tip rather than a tail
|
|
232
|
+
* subscription. The substrate writes commit-then-checkout for every
|
|
233
|
+
* ref-update, so the working tree is a coherent snapshot of the
|
|
234
|
+
* current ref tip.
|
|
235
|
+
*/
|
|
236
|
+
async function readAllEvents(opts, repoId) {
|
|
237
|
+
const entries = [];
|
|
238
|
+
const records = await enumerateEventBlobs(opts, repoId);
|
|
239
|
+
for (const r of records) {
|
|
240
|
+
const env = TimerEventEnvelope(r.payload);
|
|
241
|
+
if (env instanceof type.errors) {
|
|
242
|
+
// A non-timer event blob (StepStarted, RunStarted, ...) at the
|
|
243
|
+
// same path prefix is expected -- the scheduler skips it
|
|
244
|
+
// silently. A timer-shaped blob whose narrow fails is a
|
|
245
|
+
// substrate-level invariant violation; the kind handler's
|
|
246
|
+
// validatePush is the layer that should catch it. Here we skip
|
|
247
|
+
// to avoid crashing the scheduler over a single bad blob; a
|
|
248
|
+
// separate audit pass surfaces the integrity problem.
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
entries.push({ runId: r.runId, envelope: env });
|
|
252
|
+
}
|
|
253
|
+
return entries;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Walk the workflow-run repo's `runs/<runId>/events/<seq>.json`
|
|
257
|
+
* subtree at the current ref tip and return every event blob's
|
|
258
|
+
* payload, attributed to its run. A terminated run whose events have
|
|
259
|
+
* been compacted into a combined `events.jsonl` (no `events/` subtree)
|
|
260
|
+
* is skipped: this walk recovers pending timers, and a terminated run
|
|
261
|
+
* has none.
|
|
262
|
+
*/
|
|
263
|
+
async function enumerateEventBlobs(opts, repoId) {
|
|
264
|
+
const dir = opts.repoStore.getRepoDir(repoId);
|
|
265
|
+
const fs = await import("node:fs/promises");
|
|
266
|
+
const path = await import("node:path");
|
|
267
|
+
const runsDir = path.join(dir, "runs");
|
|
268
|
+
const out = [];
|
|
269
|
+
let runEntries;
|
|
270
|
+
try {
|
|
271
|
+
runEntries = await fs.readdir(runsDir);
|
|
272
|
+
}
|
|
273
|
+
catch (cause) {
|
|
274
|
+
if (isErrnoNotFound(cause))
|
|
275
|
+
return out;
|
|
276
|
+
throw cause;
|
|
277
|
+
}
|
|
278
|
+
for (const runId of runEntries) {
|
|
279
|
+
const eventsDir = path.join(runsDir, runId, "events");
|
|
280
|
+
let blobs;
|
|
281
|
+
try {
|
|
282
|
+
blobs = await fs.readdir(eventsDir);
|
|
283
|
+
}
|
|
284
|
+
catch (cause) {
|
|
285
|
+
if (isErrnoNotFound(cause))
|
|
286
|
+
continue;
|
|
287
|
+
throw cause;
|
|
288
|
+
}
|
|
289
|
+
for (const blob of blobs) {
|
|
290
|
+
if (!/^(0|[1-9][0-9]*)\.json$/.test(blob))
|
|
291
|
+
continue;
|
|
292
|
+
const raw = await fs.readFile(path.join(eventsDir, blob), "utf8");
|
|
293
|
+
let parsed;
|
|
294
|
+
try {
|
|
295
|
+
parsed = JSON.parse(raw);
|
|
296
|
+
}
|
|
297
|
+
catch (cause) {
|
|
298
|
+
throw new Error(`scheduler recovery: cannot parse ${String(repoId.id)}/${runId}/events/${blob}: ${String(cause)}`);
|
|
299
|
+
}
|
|
300
|
+
out.push({ runId, payload: parsed });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
function isErrnoNotFound(cause) {
|
|
306
|
+
if (cause === null || typeof cause !== "object")
|
|
307
|
+
return false;
|
|
308
|
+
const code = cause.code;
|
|
309
|
+
return code === "ENOENT";
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Commit a `TimerFired` event blob to the workflow-run repo. The
|
|
313
|
+
* commit goes through `writeTreePreservingPrefix` so concurrent
|
|
314
|
+
* commits on the same runId's events subtree serialize at the
|
|
315
|
+
* substrate's per-repo lock. Idempotent: if a TimerFired for the
|
|
316
|
+
* same `(runId, timerId)` already exists at the prefix, the merge
|
|
317
|
+
* returns the existing tree unchanged.
|
|
318
|
+
*/
|
|
319
|
+
async function commitTimerFired(opts, runId, timerId) {
|
|
320
|
+
const owningRepoId = await findOwningDeployment(opts, runId);
|
|
321
|
+
if (owningRepoId === undefined) {
|
|
322
|
+
throw new Error(`scheduler commit: cannot find deployment owning run ${runId}`);
|
|
323
|
+
}
|
|
324
|
+
const prefix = `runs/${runId}/events/`;
|
|
325
|
+
await opts.repoStore.writeTreePreservingPrefix(opts.principal, owningRepoId, opts.ref, {
|
|
326
|
+
preservePrefix: prefix,
|
|
327
|
+
merge: async (existing) => {
|
|
328
|
+
let maxSeq = -1;
|
|
329
|
+
let alreadyFired = false;
|
|
330
|
+
for (const [filepath, contents] of existing) {
|
|
331
|
+
const name = filepath.slice(prefix.length);
|
|
332
|
+
const match = /^(0|[1-9][0-9]*)\.json$/.exec(name);
|
|
333
|
+
if (match === null)
|
|
334
|
+
continue;
|
|
335
|
+
const seqStr = match[1];
|
|
336
|
+
if (seqStr === undefined)
|
|
337
|
+
continue;
|
|
338
|
+
const seq = Number.parseInt(seqStr, 10);
|
|
339
|
+
if (seq > maxSeq)
|
|
340
|
+
maxSeq = seq;
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(new TextDecoder().decode(contents));
|
|
343
|
+
if (isMatchingTimerFired(parsed, timerId)) {
|
|
344
|
+
alreadyFired = true;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Skip on parse failure -- a corrupt blob would have
|
|
349
|
+
// been rejected by validatePush at write time; treat as
|
|
350
|
+
// a non-matching entry.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const out = {};
|
|
354
|
+
for (const [filepath, contents] of existing) {
|
|
355
|
+
out[filepath] = new TextDecoder().decode(contents);
|
|
356
|
+
}
|
|
357
|
+
if (alreadyFired)
|
|
358
|
+
return out;
|
|
359
|
+
const nextSeq = maxSeq + 1;
|
|
360
|
+
out[`${prefix}${String(nextSeq)}.json`] = JSON.stringify({
|
|
361
|
+
seq: nextSeq,
|
|
362
|
+
type: "TimerFired",
|
|
363
|
+
data: { timerId },
|
|
364
|
+
});
|
|
365
|
+
return out;
|
|
366
|
+
},
|
|
367
|
+
message: `TimerFired ${timerId} for run ${runId}`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function isMatchingTimerFired(parsed, timerId) {
|
|
371
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
372
|
+
return false;
|
|
373
|
+
const obj = parsed;
|
|
374
|
+
if (obj.type !== "TimerFired")
|
|
375
|
+
return false;
|
|
376
|
+
if (obj.data === undefined)
|
|
377
|
+
return false;
|
|
378
|
+
return obj.data.timerId === timerId;
|
|
379
|
+
}
|
|
380
|
+
async function findOwningDeployment(opts, runId) {
|
|
381
|
+
const repoIds = await opts.listActiveDeployments();
|
|
382
|
+
const fs = await import("node:fs/promises");
|
|
383
|
+
const path = await import("node:path");
|
|
384
|
+
for (const repoId of repoIds) {
|
|
385
|
+
const dir = opts.repoStore.getRepoDir(repoId);
|
|
386
|
+
try {
|
|
387
|
+
await fs.access(path.join(dir, "runs", runId, "events"));
|
|
388
|
+
return repoId;
|
|
389
|
+
}
|
|
390
|
+
catch (cause) {
|
|
391
|
+
if (isErrnoNotFound(cause))
|
|
392
|
+
continue;
|
|
393
|
+
throw cause;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate";
|
|
2
|
+
import type { RunState, SignalChannel } from "@intx/workflow";
|
|
3
|
+
/**
|
|
4
|
+
* Substrate-shape envelope for the `SignalReceived` event blob
|
|
5
|
+
* committed to `runs/<runId>/events/<seq>.json`. The validator covers
|
|
6
|
+
* the single event type the signal channel both reads (live tail) and
|
|
7
|
+
* writes (deliver). Fields ride at the top level so the shape is
|
|
8
|
+
* symmetric with the runtime body's append shape -- a downstream
|
|
9
|
+
* reader that hydrates the envelope as a state-machine `WorkflowEvent`
|
|
10
|
+
* sees `signalName`/`signalId`/`payload` regardless of whether the
|
|
11
|
+
* commit came from the signal channel's `deliver` or the runtime
|
|
12
|
+
* body's `commit` of a SignalReceived after `awaitNext`. Non-signal
|
|
13
|
+
* blobs at the same path prefix do not match the kinds filter inside
|
|
14
|
+
* `subscribeKind`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SignalReceivedEnvelope: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
17
|
+
type: "SignalReceived";
|
|
18
|
+
signalName: string;
|
|
19
|
+
signalId: string;
|
|
20
|
+
payload: unknown;
|
|
21
|
+
}, {}>;
|
|
22
|
+
export type SignalReceivedEnvelope = typeof SignalReceivedEnvelope.infer;
|
|
23
|
+
export type SignalChannelOpts = {
|
|
24
|
+
/**
|
|
25
|
+
* Substrate handle the channel reads from and writes to. The caller
|
|
26
|
+
* wires this against the workflow-run kind handler's registered
|
|
27
|
+
* substrate -- the channel's writes land under
|
|
28
|
+
* `runs/<runId>/events/<seq>.json` and the handler's `validatePush`
|
|
29
|
+
* must accept that path layout.
|
|
30
|
+
*/
|
|
31
|
+
repoStore: RepoStore;
|
|
32
|
+
/**
|
|
33
|
+
* Principal the channel presents to the substrate. The substrate
|
|
34
|
+
* gates every operation behind `authorize`; the principal must be
|
|
35
|
+
* granted `writeTree` (for `deliver`) and `subscribe` (for
|
|
36
|
+
* `awaitNext`'s log tail) against the workflow-run repo.
|
|
37
|
+
*/
|
|
38
|
+
principal: Principal;
|
|
39
|
+
/** Workflow-run repo this channel operates against. */
|
|
40
|
+
repoId: RepoId;
|
|
41
|
+
/**
|
|
42
|
+
* Events ref the channel tails and writes to. The workflow-run
|
|
43
|
+
* repo layout pins all `runs/<runId>/events/` blobs under a single
|
|
44
|
+
* moving ref. Callers typically supply `"refs/heads/main"`.
|
|
45
|
+
*/
|
|
46
|
+
ref: string;
|
|
47
|
+
/**
|
|
48
|
+
* The run this channel belongs to. The channel filters
|
|
49
|
+
* `subscribeKind` entries on this runId so a host-wide events ref
|
|
50
|
+
* carrying multiple runs does not cross-resolve awaiters.
|
|
51
|
+
*/
|
|
52
|
+
runId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Reader for the in-memory `RunState`. The runtime body owns the
|
|
55
|
+
* state; the channel reads `unconsumedSignals` (pre-await
|
|
56
|
+
* delivery / resume rehydration) and `observedSignalIds` (dedup)
|
|
57
|
+
* on every `awaitNext`. A reader rather than a snapshot keeps the
|
|
58
|
+
* channel coherent with the runtime body's latest reduction.
|
|
59
|
+
*/
|
|
60
|
+
readState: () => RunState;
|
|
61
|
+
/** Generator for synthesized `signalId`s when `deliver`'s caller omits one. */
|
|
62
|
+
newId: () => string;
|
|
63
|
+
/** Clock used to stamp the committed `SignalReceived` blob's `at`. */
|
|
64
|
+
clock: () => Date;
|
|
65
|
+
};
|
|
66
|
+
export type SignalChannelHandle = SignalChannel & {
|
|
67
|
+
/**
|
|
68
|
+
* Tear down every per-name subscription and reject every pending
|
|
69
|
+
* awaiter. Idempotent. After `stop()` the channel holds no
|
|
70
|
+
* substrate watcher handles.
|
|
71
|
+
*/
|
|
72
|
+
stop(): Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
export declare function createWorkflowHostSignalChannel(opts: SignalChannelOpts): SignalChannelHandle;
|