@zgeoff/atc 2.19.0 → 2.22.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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +5 -0
  3. package/src/agents/gateway-adapter.ts +25 -2
  4. package/src/cli.ts +187 -113
  5. package/src/client/collect-agent-picks.ts +5 -2
  6. package/src/client/daemon-client.ts +29 -18
  7. package/src/daemon/build-agent-list.ts +8 -6
  8. package/src/daemon/build-auth-binding.ts +58 -0
  9. package/src/daemon/build-imp-name.ts +11 -0
  10. package/src/daemon/build-imp-provider.ts +25 -2
  11. package/src/daemon/build-payload-hash.ts +4 -3
  12. package/src/daemon/daemon-connection.ts +277 -20
  13. package/src/daemon/daemon-context.ts +8 -0
  14. package/src/daemon/daemon.ts +186 -32
  15. package/src/daemon/find-token-fingerprint.ts +23 -0
  16. package/src/daemon/handshake-throttle.ts +47 -0
  17. package/src/daemon/idempotency-ledger.ts +20 -2
  18. package/src/daemon/imp-port.ts +4 -2
  19. package/src/daemon/imp-provider.ts +24 -19
  20. package/src/daemon/is-allowed-listen-host.ts +63 -0
  21. package/src/daemon/load-listener-tokens.ts +46 -0
  22. package/src/daemon/parse-listen-address.ts +33 -0
  23. package/src/daemon/restore-fleet.ts +2 -1
  24. package/src/daemon/sessions.ts +6 -0
  25. package/src/daemon/start-tcp-listener.ts +165 -0
  26. package/src/federation/build-binding-payload-hash.ts +34 -0
  27. package/src/federation/build-daemon-outdated-error.ts +14 -0
  28. package/src/federation/build-events-filter-hash.ts +16 -0
  29. package/src/federation/build-gateway-error.ts +45 -0
  30. package/src/federation/build-gateway-id.ts +14 -0
  31. package/src/federation/build-gateway-result.ts +31 -0
  32. package/src/federation/build-ruled-value.ts +53 -0
  33. package/src/federation/collect-unruled-id-paths.ts +46 -0
  34. package/src/federation/daemon-caller.ts +473 -0
  35. package/src/federation/daemon-pool.ts +56 -0
  36. package/src/federation/decode-gateway-cursor.ts +73 -0
  37. package/src/federation/encode-gateway-cursor.ts +15 -0
  38. package/src/federation/gateway-error.ts +25 -0
  39. package/src/federation/gateway-store.ts +253 -0
  40. package/src/federation/id-rules.ts +85 -0
  41. package/src/federation/load-gateway-registry.ts +29 -0
  42. package/src/federation/max-events-cursor-bytes.ts +4 -0
  43. package/src/federation/max-registry-daemons.ts +26 -0
  44. package/src/federation/merge-event-pages.ts +228 -0
  45. package/src/federation/open-gateway-caller.ts +55 -0
  46. package/src/federation/parse-gateway-id.ts +28 -0
  47. package/src/federation/parse-gateway-registry.ts +123 -0
  48. package/src/federation/pick-daemon-state.ts +46 -0
  49. package/src/federation/plan-event-reads.ts +54 -0
  50. package/src/federation/read-fleet-events.ts +279 -0
  51. package/src/federation/require-serving-daemon.ts +27 -0
  52. package/src/federation/resolve-daemon-request.ts +59 -0
  53. package/src/federation/routing-caller.ts +450 -0
  54. package/src/federation/types.ts +33 -0
  55. package/src/federation/wait-for-outcome.ts +38 -0
  56. package/src/mcp/answer-rpc-request.ts +14 -1
  57. package/src/mcp/build-tool-list.ts +6 -5
  58. package/src/mcp/mcp-tools.ts +41 -8
  59. package/src/mcp/require-daemon-features.ts +2 -0
  60. package/src/mcp/run-tool.ts +15 -1
  61. package/src/mcp/start-mcp-http-server.ts +53 -9
  62. package/src/mcp/types.ts +8 -1
  63. package/src/protocol/daemon-features.ts +9 -0
  64. package/src/protocol/protocol.ts +2 -0
  65. package/src/protocol/request-param-schemas.ts +6 -0
  66. package/src/run-daemon-id.ts +52 -0
  67. package/src/shared/collect-auth-profiles.ts +122 -0
  68. package/src/shared/collect-gateways.ts +223 -6
  69. package/src/shared/config.ts +32 -4
  70. package/src/shared/find-daemon-record.ts +8 -3
  71. package/src/shared/resolve-auth-profiles.ts +186 -0
  72. package/src/store/run-migrations.ts +77 -0
  73. package/src/store/runtime-auth-binding.ts +110 -0
  74. package/src/store/state-store.ts +202 -0
@@ -0,0 +1,253 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+
4
+ /**
5
+ * The last outcome the gateway saw for a keyed request: `pending` until an
6
+ * answer arrives, `completed` for any answer the daemon gave other than
7
+ * `outcome_unknown`, and `uncertain` for `outcome_unknown` or a response
8
+ * that never arrived.
9
+ */
10
+ export type BindingOutcome = 'pending' | 'completed' | 'uncertain';
11
+
12
+ /**
13
+ * The daemon a keyed spawn or message went to: bound per principal,
14
+ * operation, and key before the request leaves the gateway, so every retry
15
+ * reaches the same daemon whatever the default daemon is by then. The
16
+ * daemon's announced completed-key retention, null when it announced none,
17
+ * decides when the binding may go. The payload hash binds the key to the
18
+ * request it was first used with, as the daemon's own ledger does.
19
+ */
20
+ export interface KeyBinding {
21
+ readonly principal: string;
22
+ readonly operation: string;
23
+ readonly key: string;
24
+ readonly daemon: string;
25
+ readonly daemonID: string;
26
+ readonly retentionMs: number | null;
27
+ readonly payloadHash: string;
28
+ readonly outcome: BindingOutcome;
29
+ readonly outcomeAt: number;
30
+
31
+ // When a request under the key first left for its daemon, null while
32
+ // none has, and the daemon's id of the effect an uncertain answer
33
+ // returned, null without one.
34
+ readonly sentAt: number | null;
35
+ readonly effectRef: string | null;
36
+
37
+ // The id of the claim that wrote the binding, so a call can tell its own
38
+ // binding from one another call wrote first.
39
+ readonly claimID: string;
40
+ }
41
+
42
+ interface BindingRow {
43
+ readonly principal: string;
44
+ readonly operation: string;
45
+ readonly key: string;
46
+ readonly daemon: string;
47
+ readonly daemon_id: string;
48
+ readonly retention_ms: number | null;
49
+ readonly payload_hash: string;
50
+ readonly outcome: string;
51
+ readonly outcome_at: number;
52
+ readonly sent_at: number | null;
53
+ readonly effect_ref: string | null;
54
+ readonly claim_id: string;
55
+ }
56
+
57
+ /**
58
+ * `gateway.db`: the keyed-request bindings, and nothing else the gateway
59
+ * could rebuild. A binding outlives the daemon's promise to deduplicate its
60
+ * key: a completed binding goes only once twice the daemon's announced
61
+ * retention has passed since its answer, and a pending or uncertain one,
62
+ * or one bound to a daemon that announced no retention, stays for good, as
63
+ * the daemon keeps such a key.
64
+ */
65
+ export class GatewayStore {
66
+ private readonly db: Database;
67
+
68
+ private constructor(db: Database) {
69
+ this.db = db;
70
+ }
71
+
72
+ static open(path: string): GatewayStore {
73
+ const db = new Database(path, { create: true, strict: true });
74
+
75
+ db.run('PRAGMA journal_mode = WAL');
76
+
77
+ db.run(`CREATE TABLE IF NOT EXISTS key_binding (
78
+ principal TEXT NOT NULL,
79
+ operation TEXT NOT NULL,
80
+ key TEXT NOT NULL,
81
+ daemon TEXT NOT NULL,
82
+ daemon_id TEXT NOT NULL,
83
+ retention_ms INTEGER,
84
+ payload_hash TEXT NOT NULL,
85
+ outcome TEXT NOT NULL,
86
+ outcome_at INTEGER NOT NULL,
87
+ sent_at INTEGER,
88
+ effect_ref TEXT,
89
+ claim_id TEXT NOT NULL,
90
+ PRIMARY KEY (principal, operation, key)
91
+ )`);
92
+
93
+ return new GatewayStore(db);
94
+ }
95
+
96
+ /**
97
+ * Binds the key to the daemon under the given claim id unless a binding
98
+ * for it already exists, and
99
+ * returns the binding that holds after the call: the new one, or the one
100
+ * an earlier request made, whose daemon the request must go to. Throws
101
+ * `idempotency_conflict` when the key's binding holds another payload, so
102
+ * a reused key never reaches a daemon that may have dropped it already.
103
+ */
104
+ claimBinding(
105
+ binding: Omit<KeyBinding, 'outcome' | 'outcomeAt' | 'sentAt' | 'effectRef'>,
106
+ now: number,
107
+ ): KeyBinding {
108
+ this.db
109
+ .query(
110
+ `INSERT INTO key_binding
111
+ (principal, operation, key, daemon, daemon_id, retention_ms, payload_hash, outcome, outcome_at, claim_id)
112
+ VALUES ($principal, $operation, $key, $daemon, $daemonID, $retentionMs, $payloadHash, 'pending', $now, $claimID)
113
+ ON CONFLICT (principal, operation, key) DO NOTHING`,
114
+ )
115
+ .run({
116
+ principal: binding.principal,
117
+ operation: binding.operation,
118
+ key: binding.key,
119
+ daemon: binding.daemon,
120
+ daemonID: binding.daemonID,
121
+ retentionMs: binding.retentionMs,
122
+ payloadHash: binding.payloadHash,
123
+ claimID: binding.claimID,
124
+ now,
125
+ });
126
+
127
+ const held = this.findBinding(binding.principal, binding.operation, binding.key);
128
+
129
+ if (held === null) {
130
+ throw new Error('a claimed key binding is missing');
131
+ }
132
+
133
+ if (held.payloadHash !== binding.payloadHash) {
134
+ throw new DaemonError(
135
+ 'idempotency_conflict',
136
+ `idempotency key '${binding.key}' was first used with a different ${binding.operation} payload`,
137
+ );
138
+ }
139
+
140
+ return held;
141
+ }
142
+
143
+ findBinding(principal: string, operation: string, key: string): KeyBinding | null {
144
+ const row = this.db
145
+ .query<BindingRow, { principal: string; operation: string; key: string }>(
146
+ `SELECT * FROM key_binding
147
+ WHERE principal = $principal AND operation = $operation AND key = $key`,
148
+ )
149
+ .get({ principal, operation, key });
150
+
151
+ return row === null ? null : toKeyBinding(row);
152
+ }
153
+
154
+ /**
155
+ * Takes the key's first send: marks the binding sent unless a request
156
+ * under the key was sent before, in one statement, so of any number of
157
+ * calls, across gateway processes too, exactly one takes it. Returns
158
+ * whether this call took it: only that call's request may run the
159
+ * effect, and every later one may only replay it.
160
+ */
161
+ claimFirstSend(principal: string, operation: string, key: string, now: number): boolean {
162
+ return (
163
+ this.db
164
+ .query(
165
+ `UPDATE key_binding SET sent_at = $now
166
+ WHERE principal = $principal AND operation = $operation AND key = $key
167
+ AND sent_at IS NULL`,
168
+ )
169
+ .run({ principal, operation, key, now }).changes === 1
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Records the last outcome of the key's request, and the effect id an
175
+ * uncertain answer returned, keeping one recorded earlier when this
176
+ * answer holds none. A completed outcome stays completed: a later
177
+ * request under the key whose answer never arrived changes nothing the
178
+ * daemon already answered.
179
+ */
180
+ updateOutcome(
181
+ principal: string,
182
+ operation: string,
183
+ key: string,
184
+ outcome: BindingOutcome,
185
+ now: number,
186
+ effectRef: string | null = null,
187
+ ): void {
188
+ this.db
189
+ .query(
190
+ `UPDATE key_binding
191
+ SET outcome = $outcome, outcome_at = $now, effect_ref = COALESCE($effectRef, effect_ref)
192
+ WHERE principal = $principal AND operation = $operation AND key = $key
193
+ AND (outcome != 'completed' OR $outcome = 'completed')`,
194
+ )
195
+ .run({ principal, operation, key, outcome, now, effectRef });
196
+ }
197
+
198
+ /**
199
+ * Removes the key's binding when the given claim wrote it and no request
200
+ * under the key was sent, for a request the gateway refused before it
201
+ * sent anything. A binding another claim wrote, or one sent, stays.
202
+ */
203
+ removeBinding(principal: string, operation: string, key: string, claimID: string): void {
204
+ this.db
205
+ .query(
206
+ `DELETE FROM key_binding
207
+ WHERE principal = $principal AND operation = $operation AND key = $key
208
+ AND claim_id = $claimID AND sent_at IS NULL`,
209
+ )
210
+ .run({ principal, operation, key, claimID });
211
+ }
212
+
213
+ /**
214
+ * Removes every completed binding whose daemon announced a retention and
215
+ * whose answer is older than twice that retention. Returns how many went.
216
+ */
217
+ removeExpiredBindings(now: number): number {
218
+ return this.db
219
+ .query(
220
+ `DELETE FROM key_binding
221
+ WHERE outcome = 'completed' AND retention_ms IS NOT NULL
222
+ AND outcome_at + 2 * retention_ms < $now`,
223
+ )
224
+ .run({ now }).changes;
225
+ }
226
+
227
+ stop(): void {
228
+ this.db.close();
229
+ }
230
+ }
231
+
232
+ function toKeyBinding(row: BindingRow): KeyBinding {
233
+ return {
234
+ principal: row.principal,
235
+ operation: row.operation,
236
+ key: row.key,
237
+ daemon: row.daemon,
238
+ daemonID: row.daemon_id,
239
+ retentionMs: row.retention_ms,
240
+ payloadHash: row.payload_hash,
241
+ outcome: pickOutcome(row.outcome),
242
+ outcomeAt: row.outcome_at,
243
+ sentAt: row.sent_at,
244
+ effectRef: row.effect_ref,
245
+ claimID: row.claim_id,
246
+ };
247
+ }
248
+
249
+ // A stored outcome; anything unreadable counts as uncertain, which keeps
250
+ // the binding.
251
+ function pickOutcome(value: string): BindingOutcome {
252
+ return value === 'pending' || value === 'completed' ? value : 'uncertain';
253
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * How the gateway treats one field of a daemon answer: `id` prefixes a
3
+ * daemon id with the daemon's name and incarnation, `locator` replaces the
4
+ * daemon ID in a session locator with the name and incarnation, `cursor`
5
+ * is a daemon cursor the event merge replaces with a gateway cursor, and
6
+ * `keep` passes a value that looks like an id but is none of atc's, such as
7
+ * an agent's own session id, while still rewriting ruled fields below it.
8
+ * `opaque` passes a whole value unread: text an agent or a terminal wrote,
9
+ * or a cursor only the same daemon reads back.
10
+ */
11
+ export type IDRule = 'id' | 'locator' | 'cursor' | 'keep' | 'opaque';
12
+
13
+ // The fields of a session descriptor, relative to the descriptor.
14
+ const DESCRIPTOR_RULES: readonly (readonly [string, IDRule])[] = [
15
+ ['id', 'id'],
16
+ ['parent', 'id'],
17
+ ['children[]', 'id'],
18
+ ['locator', 'locator'],
19
+ ['agentSessionID', 'keep'],
20
+ ];
21
+
22
+ /**
23
+ * The rule for every id-bearing field of each daemon method's answer the
24
+ * gateway passes on, by the field's path: object keys joined with `.`, and
25
+ * `[]` for every element of an array. An empty map is a method whose answer
26
+ * holds no id; a method missing here is one the gateway does not pass on.
27
+ */
28
+ export const ID_RULES: Readonly<Record<string, ReadonlyMap<string, IDRule>>> = {
29
+ 'agents.list': new Map(),
30
+ 'dirs.list': new Map([['dirs', 'opaque']]),
31
+ 'session.read': new Map([
32
+ ['rows', 'opaque'],
33
+ ['cursor', 'opaque'],
34
+ ]),
35
+ 'session.screen': new Map([['text', 'opaque']]),
36
+ 'session.resumeCommand': new Map([['command', 'opaque']]),
37
+ 'session.submit': new Map(),
38
+ 'session.update': new Map(),
39
+ 'session.kill': new Map(),
40
+ 'session.ack': new Map(),
41
+ 'session.list': buildPrefixedRules('sessions[].', DESCRIPTOR_RULES),
42
+ 'session.spawn': buildPrefixedRules('session.', DESCRIPTOR_RULES),
43
+ 'session.get': buildPrefixedRules('session.', DESCRIPTOR_RULES),
44
+ 'session.message': new Map([['message', 'id']]),
45
+ 'message.get': new Map([
46
+ ['message', 'id'],
47
+ ['session', 'id'],
48
+ ['answeredWith[]', 'id'],
49
+ ['turn', 'keep'],
50
+ ['turn.session', 'id'],
51
+ ]),
52
+ 'message.ack': new Map([['message', 'id']]),
53
+ 'report.get': new Map([
54
+ ['report', 'id'],
55
+ ['session', 'id'],
56
+ ['text', 'opaque'],
57
+ ]),
58
+ 'events.read': new Map([
59
+ ['events[].session', 'id'],
60
+ ['events[].message', 'id'],
61
+ ['events[].parent', 'id'],
62
+ ['events[].report', 'id'],
63
+ ['events[].cursor', 'cursor'],
64
+ ['cursor', 'cursor'],
65
+ ]),
66
+ };
67
+
68
+ /**
69
+ * The rule for every id-bearing field of an error's `data`, whatever the
70
+ * method: `effectRef` holds the session or message an uncertain or
71
+ * conflicting keyed request made.
72
+ */
73
+ export const ERROR_DATA_RULES: ReadonlyMap<string, IDRule> = new Map([
74
+ ['effectRef', 'id'],
75
+ ['session', 'id'],
76
+ ['message', 'id'],
77
+ ['parent', 'id'],
78
+ ]);
79
+
80
+ function buildPrefixedRules(
81
+ prefix: string,
82
+ rules: readonly (readonly [string, IDRule])[],
83
+ ): ReadonlyMap<string, IDRule> {
84
+ return new Map(rules.map(([path, rule]) => [`${prefix}${path}`, rule]));
85
+ }
@@ -0,0 +1,29 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseGatewayRegistry } from './parse-gateway-registry';
3
+ import type { GatewayRegistry } from './types';
4
+
5
+ type LoadedGatewayRegistry =
6
+ | { readonly ok: true; readonly registry: GatewayRegistry }
7
+ | { readonly ok: false; readonly errors: readonly string[] };
8
+
9
+ /**
10
+ * Reads the registry file and parses it with the tokens from the
11
+ * environment. An unreadable file or one that is not JSON refuses the
12
+ * registry like any other problem in it.
13
+ */
14
+ export function loadGatewayRegistry(
15
+ path: string,
16
+ env: Readonly<Record<string, string | undefined>>,
17
+ ): LoadedGatewayRegistry {
18
+ let raw: unknown;
19
+
20
+ try {
21
+ raw = JSON.parse(readFileSync(path, 'utf8'));
22
+ } catch (error) {
23
+ const detail = error instanceof Error ? error.message : String(error);
24
+
25
+ return { ok: false, errors: [`cannot read the registry at ${path}: ${detail}`] };
26
+ }
27
+
28
+ return parseGatewayRegistry(raw, env);
29
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * The largest gateway events cursor the gateway reads back, in bytes.
3
+ */
4
+ export const MAX_EVENTS_CURSOR_BYTES = 4096;
@@ -0,0 +1,26 @@
1
+ import { encodeCursor } from '../protocol/encode-cursor';
2
+ import { encodeGatewayCursor } from './encode-gateway-cursor';
3
+ import { MAX_EVENTS_CURSOR_BYTES } from './max-events-cursor-bytes';
4
+
5
+ /**
6
+ * The most daemons a registry may list: the largest count whose worst-case
7
+ * events cursor still fits the bytes the gateway reads back. The worst case
8
+ * gives every daemon a name of the longest allowed length and a position at
9
+ * the largest event id a daemon cursor can hold, under the longest filter
10
+ * hash, so any cursor a registry this size produces decodes again.
11
+ */
12
+ export const MAX_REGISTRY_DAEMONS = (() => {
13
+ const position = encodeCursor({ kind: 'events', id: Number.MAX_SAFE_INTEGER });
14
+
15
+ const parts = new Map<string, string>();
16
+
17
+ for (;;) {
18
+ const index = String(parts.size);
19
+
20
+ parts.set(`${'a'.repeat(31 - index.length)}${index}.ffffffff`, position);
21
+
22
+ if (Buffer.byteLength(encodeGatewayCursor('f'.repeat(22), parts)) > MAX_EVENTS_CURSOR_BYTES) {
23
+ return parts.size - 1;
24
+ }
25
+ }
26
+ })();
@@ -0,0 +1,228 @@
1
+ import { decodeCursor } from '../protocol/decode-cursor';
2
+ import { encodeCursor } from '../protocol/encode-cursor';
3
+ import { isRecord } from '../shared/report';
4
+ import { buildGatewayID } from './build-gateway-id';
5
+ import { buildRuledValue } from './build-ruled-value';
6
+ import { encodeGatewayCursor } from './encode-gateway-cursor';
7
+ import type { IDRule } from './id-rules';
8
+ import type { RegistryDaemon } from './types';
9
+
10
+ /**
11
+ * What one daemon gave an events read: a page of its events in its own
12
+ * order, with the cursor it returned and whether more follow; nothing,
13
+ * because it did not answer; or, for a daemon that started at its newest
14
+ * event, the cursor of that position.
15
+ */
16
+ type DaemonEventPage =
17
+ | {
18
+ readonly kind: 'read';
19
+ readonly events: readonly Readonly<Record<string, unknown>>[];
20
+ readonly cursor: string;
21
+ readonly more: boolean;
22
+ }
23
+ | { readonly kind: 'unavailable' }
24
+ | { readonly kind: 'started'; readonly cursor: string };
25
+
26
+ /**
27
+ * One daemon's part of a merge: the daemon, where its part of the cursor
28
+ * stood before the read (absent for a daemon the cursor left out, null for
29
+ * a read of its latest events), and what it gave. `unstarted` marks a page
30
+ * of the latest events of a daemon that had not answered since the read
31
+ * that started the cursor, and whether older events precede that page.
32
+ */
33
+ export interface MergeSource {
34
+ readonly daemon: Pick<RegistryDaemon, 'name' | 'incarnation'>;
35
+ readonly before: { readonly cursor: string | null } | null;
36
+ readonly page: DaemonEventPage;
37
+ readonly unstarted?: { readonly olderUnread: boolean };
38
+ }
39
+
40
+ interface MergedEvents {
41
+ readonly events: readonly Readonly<Record<string, unknown>>[];
42
+ readonly cursor: string;
43
+ readonly more: boolean;
44
+ readonly unavailable: readonly string[];
45
+ readonly started: readonly string[];
46
+ readonly truncated: readonly string[];
47
+ }
48
+
49
+ // The id fields of one event, relative to the event.
50
+ const EVENT_RULES: ReadonlyMap<string, IDRule> = new Map([
51
+ ['session', 'id'],
52
+ ['message', 'id'],
53
+ ['parent', 'id'],
54
+ ['report', 'id'],
55
+ ]);
56
+
57
+ /**
58
+ * Merges the daemons' pages of one events read into one page of at most
59
+ * `limit` events. Each daemon's events keep their own order, and daemons
60
+ * interleave by timestamp as a best effort, since daemon clocks can skew.
61
+ * Each daemon's part of the returned cursor advances only past its events
62
+ * that made it into the page, which are always a prefix of what it gave,
63
+ * so an event read but cut is read again next time. A daemon that did not
64
+ * answer keeps its part and is listed under `unavailable`, and one that had
65
+ * no position yet keeps a null part, so the next read starts it at its
66
+ * latest events rather than skipping what it queued meanwhile. A daemon
67
+ * that started at its newest event, or at its latest events after such a
68
+ * gap, is listed under `started`, and under `truncated` too when older
69
+ * events precede the latest page and went unread. A report event also
70
+ * holds `report`, its daemon-qualified handle for `report.get`. Every event's
71
+ * ids are rewritten for its daemon, and its `cursor` is the gateway cursor
72
+ * that resumes right after it.
73
+ */
74
+ export function mergeEventPages(
75
+ sources: readonly MergeSource[],
76
+ filter: string,
77
+ limit: number,
78
+ ): MergedEvents {
79
+ const parts = new Map<string, string | null>();
80
+
81
+ const queues: {
82
+ readonly source: MergeSource;
83
+ readonly events: readonly Readonly<Record<string, unknown>>[];
84
+ taken: number;
85
+ }[] = [];
86
+
87
+ const unavailable: string[] = [];
88
+ const started: string[] = [];
89
+ const truncated: string[] = [];
90
+
91
+ for (const source of sources) {
92
+ const key = `${source.daemon.name}.${source.daemon.incarnation}`;
93
+ const page = source.page;
94
+
95
+ if (page.kind === 'unavailable') {
96
+ unavailable.push(source.daemon.name);
97
+
98
+ if (source.before !== null) {
99
+ parts.set(key, source.before.cursor);
100
+ }
101
+ } else if (page.kind === 'started') {
102
+ started.push(source.daemon.name);
103
+ parts.set(key, page.cursor);
104
+ } else {
105
+ if (source.unstarted !== undefined) {
106
+ started.push(source.daemon.name);
107
+ }
108
+
109
+ if (source.unstarted?.olderUnread === true) {
110
+ truncated.push(source.daemon.name);
111
+ }
112
+
113
+ // An empty page's cursor is where the daemon stands. Until one of a
114
+ // page's events goes out, its daemon resumes right before the first
115
+ // one, a position the daemon reads as concrete, unlike no cursor,
116
+ // which it reads as its latest events.
117
+ const [first] = page.events;
118
+ const position = first === undefined ? page.cursor : buildPositionBefore(first);
119
+
120
+ parts.set(key, position);
121
+ queues.push({ source, events: page.events, taken: 0 });
122
+ }
123
+ }
124
+
125
+ const events: Readonly<Record<string, unknown>>[] = [];
126
+
127
+ while (events.length < limit) {
128
+ const next = pickNextQueue(queues);
129
+
130
+ if (next === null) {
131
+ break;
132
+ }
133
+
134
+ const event = next.events[next.taken] ?? {};
135
+ const daemon = next.source.daemon;
136
+
137
+ next.taken++;
138
+
139
+ if (typeof event['cursor'] === 'string') {
140
+ parts.set(`${daemon.name}.${daemon.incarnation}`, event['cursor']);
141
+ }
142
+
143
+ const rewritten = buildRuledValue(event, EVENT_RULES, daemon);
144
+ const handle = findReportHandle(event, daemon);
145
+
146
+ events.push({
147
+ ...(isRecord(rewritten) ? rewritten : event),
148
+ cursor: encodeGatewayCursor(filter, parts),
149
+ ...(handle === null ? {} : { report: handle }),
150
+ });
151
+ }
152
+
153
+ const more = queues.some(
154
+ (queue) =>
155
+ queue.taken < queue.events.length ||
156
+ (queue.source.page.kind === 'read' && queue.source.page.more),
157
+ );
158
+
159
+ return {
160
+ events,
161
+ cursor: encodeGatewayCursor(filter, parts),
162
+ more,
163
+ unavailable,
164
+ started,
165
+ truncated,
166
+ };
167
+ }
168
+
169
+ // A report event's handle for `report.get`: the daemon's own cursor of the
170
+ // event, which the daemon reads a report by, under the daemon's name and
171
+ // incarnation, kept apart from the merged feed cursor that replaces it.
172
+ function findReportHandle(
173
+ event: Readonly<Record<string, unknown>>,
174
+ daemon: Pick<RegistryDaemon, 'name' | 'incarnation'>,
175
+ ): string | null {
176
+ const raw = event['cursor'];
177
+
178
+ return event['kind'] === 'report' && typeof raw === 'string' ? buildGatewayID(daemon, raw) : null;
179
+ }
180
+
181
+ // The queue whose next event goes out next: the one with the earliest
182
+ // timestamp, ties going to the daemon that sorts first by name, or null
183
+ // once every queue is spent.
184
+ function pickNextQueue<
185
+ T extends {
186
+ readonly source: MergeSource;
187
+ readonly events: readonly Readonly<Record<string, unknown>>[];
188
+ readonly taken: number;
189
+ },
190
+ >(queues: readonly T[]): T | null {
191
+ let best: T | null = null;
192
+ let bestAt = Number.POSITIVE_INFINITY;
193
+
194
+ for (const queue of queues) {
195
+ const head = queue.events[queue.taken];
196
+
197
+ if (head === undefined) {
198
+ continue;
199
+ }
200
+
201
+ const at = typeof head['at'] === 'number' ? head['at'] : Number.POSITIVE_INFINITY;
202
+
203
+ if (
204
+ best === null ||
205
+ at < bestAt ||
206
+ (at === bestAt && queue.source.daemon.name < best.source.daemon.name)
207
+ ) {
208
+ best = queue;
209
+ bestAt = at;
210
+ }
211
+ }
212
+
213
+ return best;
214
+ }
215
+
216
+ // The daemon cursor that resumes a read right before an event, from the
217
+ // event's own cursor. Throws for an event whose cursor is not a daemon's
218
+ // events cursor, which no daemon sends.
219
+ function buildPositionBefore(event: Readonly<Record<string, unknown>>): string {
220
+ const raw = event['cursor'];
221
+ const decoded = typeof raw === 'string' ? decodeCursor(raw) : null;
222
+
223
+ if (decoded === null || decoded.kind !== 'events') {
224
+ throw new Error('a daemon event holds no events cursor');
225
+ }
226
+
227
+ return encodeCursor({ kind: 'events', id: decoded.id - 1 });
228
+ }
@@ -0,0 +1,55 @@
1
+ import type { GatewayChannel } from './daemon-caller';
2
+ import { DaemonPool } from './daemon-pool';
3
+ import { GatewayStore } from './gateway-store';
4
+ import { RoutingCaller } from './routing-caller';
5
+ import type { GatewayRegistry, RegistryDaemon } from './types';
6
+
7
+ interface GatewayCallerOptions {
8
+ readonly registry: GatewayRegistry;
9
+ readonly build: string;
10
+
11
+ // Connects to a daemon's TCP address.
12
+ readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
13
+
14
+ // The keyed-request bindings.
15
+ readonly gatewayDBPath: string;
16
+
17
+ // How long each daemon may take to answer a call asked of every daemon.
18
+ readonly fanOutTimeoutMs?: number;
19
+ }
20
+
21
+ /**
22
+ * The caller the gateway's MCP tools ride: one pooled connection per
23
+ * registry daemon, the binding store, and the router over them, which the
24
+ * entrypoint hands to the MCP HTTP server. Nothing dials a daemon until a
25
+ * call needs it, so a daemon that is down never holds up the start. `stop`
26
+ * closes every daemon connection and the store.
27
+ */
28
+ export function openGatewayCaller(opts: GatewayCallerOptions): {
29
+ readonly caller: RoutingCaller;
30
+ readonly stop: () => Promise<void>;
31
+ } {
32
+ const store = GatewayStore.open(opts.gatewayDBPath);
33
+
34
+ const pool = new DaemonPool({
35
+ registry: opts.registry,
36
+ build: opts.build,
37
+ openChannel: opts.openChannel,
38
+ });
39
+
40
+ const caller = new RoutingCaller({
41
+ registry: opts.registry,
42
+ pool,
43
+ store,
44
+ ...(opts.fanOutTimeoutMs === undefined ? {} : { fanOutTimeoutMs: opts.fanOutTimeoutMs }),
45
+ });
46
+
47
+ return {
48
+ caller,
49
+ stop: async () => {
50
+ await pool.stop();
51
+
52
+ store.stop();
53
+ },
54
+ };
55
+ }