@zgeoff/atc 2.20.0 → 2.23.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +187 -118
  3. package/src/client/daemon-client.ts +29 -18
  4. package/src/daemon/build-payload-hash.ts +4 -3
  5. package/src/daemon/daemon-connection.ts +270 -20
  6. package/src/daemon/daemon-context.ts +8 -0
  7. package/src/daemon/daemon.ts +186 -32
  8. package/src/daemon/find-token-fingerprint.ts +23 -0
  9. package/src/daemon/handshake-throttle.ts +47 -0
  10. package/src/daemon/idempotency-ledger.ts +20 -2
  11. package/src/daemon/is-allowed-listen-host.ts +63 -0
  12. package/src/daemon/load-listener-tokens.ts +46 -0
  13. package/src/daemon/parse-listen-address.ts +33 -0
  14. package/src/daemon/start-tcp-listener.ts +165 -0
  15. package/src/federation/build-binding-payload-hash.ts +34 -0
  16. package/src/federation/build-daemon-outdated-error.ts +14 -0
  17. package/src/federation/build-events-filter-hash.ts +16 -0
  18. package/src/federation/build-gateway-error.ts +45 -0
  19. package/src/federation/build-gateway-id.ts +14 -0
  20. package/src/federation/build-gateway-result.ts +31 -0
  21. package/src/federation/build-ruled-value.ts +53 -0
  22. package/src/federation/collect-unruled-id-paths.ts +46 -0
  23. package/src/federation/daemon-caller.ts +473 -0
  24. package/src/federation/daemon-pool.ts +56 -0
  25. package/src/federation/decode-gateway-cursor.ts +73 -0
  26. package/src/federation/encode-gateway-cursor.ts +15 -0
  27. package/src/federation/gateway-error.ts +25 -0
  28. package/src/federation/gateway-store.ts +253 -0
  29. package/src/federation/id-rules.ts +85 -0
  30. package/src/federation/load-gateway-registry.ts +29 -0
  31. package/src/federation/max-events-cursor-bytes.ts +4 -0
  32. package/src/federation/max-registry-daemons.ts +26 -0
  33. package/src/federation/merge-event-pages.ts +228 -0
  34. package/src/federation/open-gateway-caller.ts +55 -0
  35. package/src/federation/parse-gateway-id.ts +28 -0
  36. package/src/federation/parse-gateway-registry.ts +123 -0
  37. package/src/federation/pick-daemon-state.ts +46 -0
  38. package/src/federation/plan-event-reads.ts +54 -0
  39. package/src/federation/read-fleet-events.ts +279 -0
  40. package/src/federation/require-serving-daemon.ts +27 -0
  41. package/src/federation/resolve-daemon-request.ts +59 -0
  42. package/src/federation/routing-caller.ts +450 -0
  43. package/src/federation/types.ts +33 -0
  44. package/src/federation/wait-for-outcome.ts +38 -0
  45. package/src/mcp/answer-rpc-request.ts +14 -1
  46. package/src/mcp/build-tool-list.ts +6 -5
  47. package/src/mcp/mcp-tools.ts +41 -8
  48. package/src/mcp/require-daemon-features.ts +2 -0
  49. package/src/mcp/run-tool.ts +15 -1
  50. package/src/mcp/start-mcp-http-server.ts +53 -9
  51. package/src/mcp/types.ts +8 -1
  52. package/src/protocol/daemon-features.ts +9 -0
  53. package/src/protocol/protocol.ts +1 -0
  54. package/src/protocol/request-param-schemas.ts +6 -0
  55. package/src/run-daemon-id.ts +52 -0
  56. package/src/shared/find-daemon-record.ts +8 -3
  57. package/src/store/state-store.ts +16 -0
@@ -0,0 +1,28 @@
1
+ import type { GatewayRegistry, RegistryDaemon } from './types';
2
+
3
+ /**
4
+ * The daemon a gateway id routes to and the daemon's own id inside it, or
5
+ * null for an id the gateway must answer as one that never existed: a
6
+ * malformed id, an unknown daemon name, or an incarnation other than the
7
+ * one the registry pins behind the name.
8
+ */
9
+ export function parseGatewayID(
10
+ value: string,
11
+ registry: GatewayRegistry,
12
+ ): { readonly daemon: RegistryDaemon; readonly id: string } | null {
13
+ const first = value.indexOf('.');
14
+ const second = first === -1 ? -1 : value.indexOf('.', first + 1);
15
+
16
+ if (second === -1) {
17
+ return null;
18
+ }
19
+
20
+ const daemon = registry.daemons.get(value.slice(0, first));
21
+ const id = value.slice(second + 1);
22
+
23
+ if (daemon === undefined || value.slice(first + 1, second) !== daemon.incarnation || id === '') {
24
+ return null;
25
+ }
26
+
27
+ return { daemon, id };
28
+ }
@@ -0,0 +1,123 @@
1
+ import { isRecord } from '../shared/report';
2
+ import { MAX_EVENTS_CURSOR_BYTES } from './max-events-cursor-bytes';
3
+ import { MAX_REGISTRY_DAEMONS } from './max-registry-daemons';
4
+ import type { GatewayRegistry, RegistryDaemon } from './types';
5
+
6
+ // A daemon name: never a `.`, which separates the parts of a gateway id.
7
+ const NAME_PATTERN = /^[a-z][a-z0-9-]{0,30}$/;
8
+
9
+ // A daemon ID as a daemon mints it: a lowercase UUID.
10
+ const DAEMON_ID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/;
11
+
12
+ // `<host>:<port>`, with an IPv6 host in brackets.
13
+ const ADDRESS_PATTERN = /^(?:\[(?<v6>[^\]]+)\]|(?<host>[^:[\]]+)):(?<port>\d{1,5})$/;
14
+
15
+ type ParsedGatewayRegistry =
16
+ | { readonly ok: true; readonly registry: GatewayRegistry }
17
+ | { readonly ok: false; readonly errors: readonly string[] };
18
+
19
+ /**
20
+ * Parses a registry file's JSON and the environment the tokens come from.
21
+ * Every daemon needs a name matching `^[a-z][a-z0-9-]{0,30}$`, an address,
22
+ * a pinned `daemonID`, and a non-empty token in `ATC_GATEWAY_TOKEN_<NAME>`
23
+ * (the name upper-cased, `-` as `_`). `defaultDaemon` is required and must
24
+ * be one of the daemons. A registry lists at most as many daemons as an
25
+ * events cursor across all of them can hold. Any problem refuses the whole registry, so the
26
+ * gateway never starts with part of its fleet.
27
+ */
28
+ export function parseGatewayRegistry(
29
+ raw: unknown,
30
+ env: Readonly<Record<string, string | undefined>>,
31
+ ): ParsedGatewayRegistry {
32
+ if (!isRecord(raw) || !isRecord(raw['daemons']) || Array.isArray(raw['daemons'])) {
33
+ return { ok: false, errors: ['the registry must be an object whose daemons is an object'] };
34
+ }
35
+
36
+ const daemons = new Map<string, RegistryDaemon>();
37
+
38
+ const errors: string[] = [];
39
+
40
+ for (const [name, entry] of Object.entries(raw['daemons'])) {
41
+ const parsed = parseRegistryDaemon(name, entry, env);
42
+
43
+ if (typeof parsed === 'string') {
44
+ errors.push(parsed);
45
+ } else {
46
+ daemons.set(name, parsed);
47
+ }
48
+ }
49
+
50
+ const count = Object.keys(raw['daemons']).length;
51
+
52
+ if (count === 0) {
53
+ errors.push('the registry lists no daemon');
54
+ }
55
+
56
+ if (count > MAX_REGISTRY_DAEMONS) {
57
+ errors.push(
58
+ `the registry lists ${count} daemons, over the limit of ${MAX_REGISTRY_DAEMONS}, the most whose events cursor fits ${MAX_EVENTS_CURSOR_BYTES} bytes`,
59
+ );
60
+ }
61
+
62
+ const defaultDaemon = raw['defaultDaemon'];
63
+
64
+ if (typeof defaultDaemon !== 'string') {
65
+ errors.push('defaultDaemon is required and must name a daemon in the registry');
66
+ } else if (!Object.hasOwn(raw['daemons'], defaultDaemon)) {
67
+ errors.push(`defaultDaemon '${defaultDaemon}' is not a daemon in the registry`);
68
+ }
69
+
70
+ if (errors.length > 0 || typeof defaultDaemon !== 'string') {
71
+ return { ok: false, errors };
72
+ }
73
+
74
+ return { ok: true, registry: { daemons, defaultDaemon } };
75
+ }
76
+
77
+ // One daemon entry, or the problem that refuses it.
78
+ function parseRegistryDaemon(
79
+ name: string,
80
+ entry: unknown,
81
+ env: Readonly<Record<string, string | undefined>>,
82
+ ): RegistryDaemon | string {
83
+ if (!NAME_PATTERN.test(name)) {
84
+ return `daemon name '${name}' must match ^[a-z][a-z0-9-]{0,30}$`;
85
+ }
86
+
87
+ if (!isRecord(entry)) {
88
+ return `daemon '${name}' must be an object with address and daemonID`;
89
+ }
90
+
91
+ const address = typeof entry['address'] === 'string' ? parseAddress(entry['address']) : null;
92
+
93
+ if (address === null) {
94
+ return `daemon '${name}' needs an address of <host>:<port> with a port from 1 to 65535`;
95
+ }
96
+
97
+ const daemonID = entry['daemonID'];
98
+
99
+ if (typeof daemonID !== 'string' || !DAEMON_ID_PATTERN.test(daemonID)) {
100
+ return `daemon '${name}' needs the daemonID that atc daemon id prints on its host`;
101
+ }
102
+
103
+ const tokenVar = `ATC_GATEWAY_TOKEN_${name.toUpperCase().replaceAll('-', '_')}`;
104
+ const token = env[tokenVar];
105
+
106
+ if (token === undefined || token === '') {
107
+ return `daemon '${name}' has no token: set ${tokenVar}`;
108
+ }
109
+
110
+ return { name, address, daemonID, incarnation: daemonID.slice(0, 8), token };
111
+ }
112
+
113
+ function parseAddress(raw: string): { readonly host: string; readonly port: number } | null {
114
+ const match = ADDRESS_PATTERN.exec(raw);
115
+ const host = match?.groups?.['v6'] ?? match?.groups?.['host'];
116
+ const port = Number(match?.groups?.['port']);
117
+
118
+ if (host === undefined || !Number.isInteger(port) || port < 1 || port > 65_535) {
119
+ return null;
120
+ }
121
+
122
+ return { host, port };
123
+ }
@@ -0,0 +1,46 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import { GatewayError } from './gateway-error';
3
+ import type { CallOutcome } from './wait-for-outcome';
4
+
5
+ /**
6
+ * A daemon's state as the gateway reports it: `up` when it answered,
7
+ * `down` when it was unreachable or did not answer in time, `unauthorized`
8
+ * when it refused the gateway's token, `changed` when another state
9
+ * identity than the registry pins answered, `outdated` when it lacks a
10
+ * feature the call needs, and `refused` when it answered the call with its
11
+ * own error.
12
+ */
13
+ export type DaemonState = 'up' | 'down' | 'unauthorized' | 'changed' | 'outdated' | 'refused';
14
+
15
+ /**
16
+ * The state a daemon is in, judged by how one call to it settled.
17
+ */
18
+ export function pickDaemonState(outcome: CallOutcome<unknown>): DaemonState {
19
+ if (outcome.kind === 'answered') {
20
+ return 'up';
21
+ }
22
+
23
+ if (outcome.kind === 'timeout') {
24
+ return 'down';
25
+ }
26
+
27
+ const error = outcome.error;
28
+
29
+ if (error instanceof GatewayError && error.code === 'daemon_unauthorized') {
30
+ return 'unauthorized';
31
+ }
32
+
33
+ if (error instanceof GatewayError && error.code === 'daemon_outdated') {
34
+ return 'outdated';
35
+ }
36
+
37
+ if (error instanceof GatewayError && error.data['reason'] === 'daemon_changed') {
38
+ return 'changed';
39
+ }
40
+
41
+ if (error instanceof DaemonError) {
42
+ return 'refused';
43
+ }
44
+
45
+ return 'down';
46
+ }
@@ -0,0 +1,54 @@
1
+ import { decodeGatewayCursor } from './decode-gateway-cursor';
2
+ import type { GatewayRegistry } from './types';
3
+
4
+ /**
5
+ * Where one daemon's part of an events read starts: after its own cursor,
6
+ * or at its latest events for null, which only a read without a gateway
7
+ * cursor gets; or at its newest event, for a daemon a given gateway cursor
8
+ * leaves out, such as one added to the registry since. The caller reads
9
+ * such a daemon once with `events.read` at limit 1 and no cursor, and
10
+ * pins it at the cursor that answer returns, which is its newest event,
11
+ * or event 0 for an empty trail; the read then reports it under `started`.
12
+ * A daemon whose part the cursor holds as null, because it has not answered
13
+ * since the read that started the cursor, starts at its latest events, as a
14
+ * first read does: the caller reads it without a cursor, checks whether
15
+ * older events precede that page, and the read reports it under `started`,
16
+ * and under `truncated` when older events went unread.
17
+ */
18
+ export type EventReadStart =
19
+ | { readonly kind: 'after'; readonly cursor: string | null }
20
+ | { readonly kind: 'newest' }
21
+ | { readonly kind: 'latest' };
22
+
23
+ /**
24
+ * The daemons an events read asks, each with where its part starts. A read
25
+ * filtered to a session asks only the daemon that owns the session; any
26
+ * other read asks every daemon in the registry. Without a gateway cursor
27
+ * every daemon starts at the start of its trail, as a daemon read without
28
+ * a cursor does.
29
+ */
30
+ export function planEventReads(
31
+ cursor: string | null,
32
+ filter: string,
33
+ sessionDaemon: string | null,
34
+ registry: GatewayRegistry,
35
+ ): ReadonlyMap<string, EventReadStart> {
36
+ const names = sessionDaemon === null ? [...registry.daemons.keys()] : [sessionDaemon];
37
+ const parts = cursor === null ? null : decodeGatewayCursor(cursor, filter, registry);
38
+
39
+ const plan = new Map<string, EventReadStart>();
40
+
41
+ for (const name of names) {
42
+ if (parts === null) {
43
+ plan.set(name, { kind: 'after', cursor: null });
44
+ } else if (parts.get(name) === null) {
45
+ plan.set(name, { kind: 'latest' });
46
+ } else if (parts.has(name)) {
47
+ plan.set(name, { kind: 'after', cursor: parts.get(name) ?? null });
48
+ } else {
49
+ plan.set(name, { kind: 'newest' });
50
+ }
51
+ }
52
+
53
+ return plan;
54
+ }
@@ -0,0 +1,279 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
+ import { decodeCursor } from '../protocol/decode-cursor';
4
+ import { encodeCursor } from '../protocol/encode-cursor';
5
+ import { isRecord } from '../shared/report';
6
+ import { buildEventsFilterHash } from './build-events-filter-hash';
7
+ import { buildGatewayError } from './build-gateway-error';
8
+ import type { DaemonCaller } from './daemon-caller';
9
+ import { GatewayError } from './gateway-error';
10
+ import { mergeEventPages } from './merge-event-pages';
11
+ import type { MergeSource } from './merge-event-pages';
12
+ import { parseGatewayID } from './parse-gateway-id';
13
+ import { planEventReads } from './plan-event-reads';
14
+ import type { EventReadStart } from './plan-event-reads';
15
+ import type { GatewayRegistry, RegistryDaemon } from './types';
16
+ import { waitForOutcome } from './wait-for-outcome';
17
+
18
+ interface FleetEventsDeps {
19
+ readonly registry: GatewayRegistry;
20
+ readonly getCaller: (name: string) => DaemonCaller;
21
+
22
+ // How long each daemon may take to answer a read that does not wait.
23
+ readonly timeoutMs: number;
24
+ }
25
+
26
+ // The daemon's own default and bounds for a page of events.
27
+ const DEFAULT_LIMIT = 50;
28
+ const MAX_LIMIT = 200;
29
+ const MAX_WAIT_MS = 30_000;
30
+
31
+ /**
32
+ * One `events.read` across the fleet: every daemon the plan asks is read in
33
+ * parallel, each within the fan-out time, and the pages merge into one page
34
+ * under a gateway cursor. A daemon that does not answer in time, or cannot
35
+ * be reached, is listed under `unavailable` and keeps its position. A read
36
+ * filtered to one session asks only its daemon, and that daemon's own
37
+ * refusal, or its lack of the session filter, fails the call. With
38
+ * `waitMs`, the daemons are first read without waiting; only when none has
39
+ * an event does each wait, and the call returns as soon as one of them has
40
+ * an event, the others keeping their positions.
41
+ */
42
+ export async function readFleetEvents(
43
+ deps: FleetEventsDeps,
44
+ params: Readonly<Record<string, unknown>>,
45
+ required: readonly DaemonFeature[],
46
+ principal: string,
47
+ ): Promise<Readonly<Record<string, unknown>>> {
48
+ const rawSession = params['session'];
49
+ const session = typeof rawSession === 'string' && rawSession !== '' ? rawSession : null;
50
+ const owner = session === null ? null : parseGatewayID(session, deps.registry);
51
+
52
+ if (session !== null && owner === null) {
53
+ throw new DaemonError('no_such_session', `no session '${session}'`);
54
+ }
55
+
56
+ const filter = buildEventsFilterHash(session, null);
57
+ const cursor = typeof params['cursor'] === 'string' ? params['cursor'] : null;
58
+ const limit = toBounded(params['limit'], DEFAULT_LIMIT, 1, MAX_LIMIT);
59
+ const waitMs = toBounded(params['waitMs'], 0, 0, MAX_WAIT_MS);
60
+ const plan = planEventReads(cursor, filter, owner?.daemon.name ?? null, deps.registry);
61
+
62
+ const requestIDs: [string, string][] =
63
+ owner === null || session === null ? [] : [[owner.id, session]];
64
+
65
+ const read: DaemonRead = {
66
+ deps,
67
+ required,
68
+ principal,
69
+ limit,
70
+ sessionID: owner?.id ?? null,
71
+ requestIDs: new Map(requestIDs),
72
+ };
73
+
74
+ const sources = await Promise.all(
75
+ [...plan].map(([name, start]) => readDaemonSource(read, getDaemon(deps.registry, name), start)),
76
+ );
77
+
78
+ const first = mergeEventPages(sources, filter, limit);
79
+
80
+ if (first.events.length > 0 || waitMs === 0) {
81
+ return { ...first };
82
+ }
83
+
84
+ const waited = await waitForFirstEvents(read, sources, waitMs);
85
+
86
+ return { ...mergeEventPages(waited, filter, limit) };
87
+ }
88
+
89
+ // What every daemon read of one call shares.
90
+ interface DaemonRead {
91
+ readonly deps: FleetEventsDeps;
92
+ readonly required: readonly DaemonFeature[];
93
+ readonly principal: string;
94
+ readonly limit: number;
95
+
96
+ // The daemon's own id of the session a filtered read asks about.
97
+ readonly sessionID: string | null;
98
+ readonly requestIDs: ReadonlyMap<string, string>;
99
+ }
100
+
101
+ function getDaemon(registry: GatewayRegistry, name: string): RegistryDaemon {
102
+ const daemon = registry.daemons.get(name);
103
+
104
+ if (daemon === undefined) {
105
+ throw new Error(`no daemon '${name}' in the registry`);
106
+ }
107
+
108
+ return daemon;
109
+ }
110
+
111
+ // One daemon's part of the merge, from where the plan starts it. Only a
112
+ // filtered read's daemon fails the call, and only with its own refusal or
113
+ // a missing feature; any other failure is a daemon that is unavailable.
114
+ async function readDaemonSource(
115
+ read: DaemonRead,
116
+ daemon: RegistryDaemon,
117
+ start: EventReadStart,
118
+ ): Promise<MergeSource> {
119
+ const outcome = await waitForOutcome(readDaemonPart(read, daemon, start), read.deps.timeoutMs);
120
+
121
+ if (outcome.kind === 'answered') {
122
+ return outcome.value;
123
+ }
124
+
125
+ const error = outcome.kind === 'failed' ? outcome.error : null;
126
+
127
+ if (read.sessionID !== null && error instanceof DaemonError) {
128
+ throw buildGatewayError(error, daemon, read.requestIDs);
129
+ }
130
+
131
+ if (
132
+ read.sessionID !== null &&
133
+ error instanceof GatewayError &&
134
+ error.code === 'daemon_outdated'
135
+ ) {
136
+ throw error;
137
+ }
138
+
139
+ const before = start.kind === 'after' ? { cursor: start.cursor } : null;
140
+ const unread = start.kind === 'latest' ? { cursor: null } : before;
141
+
142
+ return { daemon, before: unread, page: { kind: 'unavailable' } };
143
+ }
144
+
145
+ async function readDaemonPart(
146
+ read: DaemonRead,
147
+ daemon: RegistryDaemon,
148
+ start: EventReadStart,
149
+ ): Promise<MergeSource> {
150
+ if (start.kind === 'newest') {
151
+ const newest = await sendEventsRead(read, daemon, { limit: 1 });
152
+
153
+ return { daemon, before: null, page: { kind: 'started', cursor: newest.cursor } };
154
+ }
155
+
156
+ if (start.kind === 'after') {
157
+ const page = await sendEventsRead(read, daemon, {
158
+ limit: read.limit,
159
+ ...(start.cursor === null ? {} : { cursor: start.cursor }),
160
+ });
161
+
162
+ return { daemon, before: { cursor: start.cursor }, page };
163
+ }
164
+
165
+ const page = await sendEventsRead(read, daemon, { limit: read.limit });
166
+ const olderUnread = await hasOlderEvents(read, daemon, page.events);
167
+
168
+ return { daemon, before: { cursor: null }, page, unstarted: { olderUnread } };
169
+ }
170
+
171
+ // Whether the daemon holds an event older than the first of a page of its
172
+ // latest events, read as the first event after the start of its trail.
173
+ async function hasOlderEvents(
174
+ read: DaemonRead,
175
+ daemon: RegistryDaemon,
176
+ events: readonly Readonly<Record<string, unknown>>[],
177
+ ): Promise<boolean> {
178
+ const [first] = events;
179
+ const firstID = first === undefined ? null : findEventID(first);
180
+
181
+ if (firstID === null) {
182
+ return false;
183
+ }
184
+
185
+ const oldest = await sendEventsRead(read, daemon, {
186
+ limit: 1,
187
+ cursor: encodeCursor({ kind: 'events', id: 0 }),
188
+ });
189
+
190
+ const [oldestEvent] = oldest.events;
191
+ const oldestID = oldestEvent === undefined ? null : findEventID(oldestEvent);
192
+
193
+ return oldestID !== null && oldestID < firstID;
194
+ }
195
+
196
+ function findEventID(event: Readonly<Record<string, unknown>>): number | null {
197
+ const raw = event['cursor'];
198
+ const decoded = typeof raw === 'string' ? decodeCursor(raw) : null;
199
+
200
+ return decoded !== null && decoded.kind === 'events' ? decoded.id : null;
201
+ }
202
+
203
+ // One events.read to one daemon as the call's principal, with the
204
+ // session filter when the read has one.
205
+ async function sendEventsRead(
206
+ read: DaemonRead,
207
+ daemon: RegistryDaemon,
208
+ params: Readonly<Record<string, unknown>>,
209
+ ): Promise<{
210
+ readonly kind: 'read';
211
+ readonly events: readonly Readonly<Record<string, unknown>>[];
212
+ readonly cursor: string;
213
+ readonly more: boolean;
214
+ }> {
215
+ const answer = await read.deps
216
+ .getCaller(daemon.name)
217
+ .sendRequest(
218
+ 'events.read',
219
+ { ...params, ...(read.sessionID === null ? {} : { session: read.sessionID }) },
220
+ read.principal,
221
+ read.required,
222
+ );
223
+
224
+ const rawEvents: unknown = answer['events'];
225
+ const events = Array.isArray(rawEvents) ? rawEvents.filter((event) => isRecord(event)) : [];
226
+
227
+ return {
228
+ kind: 'read',
229
+ events,
230
+ cursor: typeof answer['cursor'] === 'string' ? answer['cursor'] : '',
231
+ more: answer['more'] === true,
232
+ };
233
+ }
234
+
235
+ // Reads again, waiting, each daemon whose page came back empty, and settles
236
+ // once one of them answers with an event or all of them have answered. A
237
+ // daemon still waiting then keeps the empty page it gave before.
238
+ async function waitForFirstEvents(
239
+ read: DaemonRead,
240
+ sources: readonly MergeSource[],
241
+ waitMs: number,
242
+ ): Promise<readonly MergeSource[]> {
243
+ const waited = new Map<MergeSource, MergeSource>();
244
+
245
+ const firstEvents = Promise.withResolvers<void>();
246
+
247
+ const reads = sources.map(async (source) => {
248
+ if (source.page.kind !== 'read') {
249
+ return;
250
+ }
251
+
252
+ const outcome = await waitForOutcome(
253
+ sendEventsRead(read, getDaemon(read.deps.registry, source.daemon.name), {
254
+ limit: read.limit,
255
+ waitMs,
256
+ cursor: source.page.cursor,
257
+ }),
258
+ read.deps.timeoutMs + waitMs,
259
+ );
260
+
261
+ if (outcome.kind === 'answered') {
262
+ waited.set(source, { ...source, page: outcome.value });
263
+ }
264
+
265
+ if (outcome.kind === 'answered' && outcome.value.events.length > 0) {
266
+ firstEvents.resolve();
267
+ }
268
+ });
269
+
270
+ await Promise.race([firstEvents.promise, Promise.all(reads)]);
271
+
272
+ return sources.map((source) => waited.get(source) ?? source);
273
+ }
274
+
275
+ function toBounded(value: unknown, fallback: number, min: number, max: number): number {
276
+ const given = typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
277
+
278
+ return Math.min(Math.max(given, min), max);
279
+ }
@@ -0,0 +1,27 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+ import { buildDaemonOutdatedError } from './build-daemon-outdated-error';
3
+ import type { DaemonCaller, DaemonHello } from './daemon-caller';
4
+ import type { RegistryDaemon } from './types';
5
+
6
+ /**
7
+ * The handshake of the daemon a call is about to reach, read from the
8
+ * connection the call rides. Throws `daemon_outdated` with the daemon's
9
+ * name when that daemon lacks a feature the call needs, so a daemon that
10
+ * would ignore an option never answers as if it had honoured it, and
11
+ * whatever the daemon caller throws when the daemon cannot be reached.
12
+ */
13
+ export async function requireServingDaemon(
14
+ getCaller: (name: string) => DaemonCaller,
15
+ daemon: RegistryDaemon,
16
+ required: readonly DaemonFeature[],
17
+ ): Promise<DaemonHello> {
18
+ const hello = await getCaller(daemon.name).readHello();
19
+
20
+ const missing = required.find((feature) => !hello.features.has(feature));
21
+
22
+ if (missing !== undefined) {
23
+ throw buildDaemonOutdatedError(daemon.name, missing);
24
+ }
25
+
26
+ return hello;
27
+ }
@@ -0,0 +1,59 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import { parseGatewayID } from './parse-gateway-id';
3
+ import type { GatewayRegistry, RegistryDaemon } from './types';
4
+
5
+ // The request params that hold a gateway id, and the refusal a daemon
6
+ // gives for an id it does not hold, which the gateway gives for an id that
7
+ // routes nowhere.
8
+ const ID_PARAMS: readonly (readonly [string, 'session' | 'message' | 'report'])[] = [
9
+ ['session', 'session'],
10
+ ['parent', 'session'],
11
+ ['message', 'message'],
12
+ ['report', 'report'],
13
+ ];
14
+
15
+ /**
16
+ * A daemon request's route: the daemon its id params point at, or null
17
+ * for a request with none; the params with each gateway id replaced by the
18
+ * daemon's own id; and each daemon id mapped back to the gateway id the
19
+ * caller sent, for errors that quote it. An id that is malformed, holds
20
+ * an unknown name or a stale incarnation, or points at another daemon than
21
+ * the request's other ids gets the refusal a daemon gives for an id it
22
+ * never held.
23
+ */
24
+ export function resolveDaemonRequest(
25
+ params: Readonly<Record<string, unknown>>,
26
+ registry: GatewayRegistry,
27
+ ): {
28
+ readonly daemon: RegistryDaemon | null;
29
+ readonly params: Readonly<Record<string, unknown>>;
30
+ readonly requestIDs: ReadonlyMap<string, string>;
31
+ } {
32
+ let daemon: RegistryDaemon | null = null;
33
+ const rewritten: Record<string, unknown> = { ...params };
34
+
35
+ const requestIDs = new Map<string, string>();
36
+
37
+ for (const [field, kind] of ID_PARAMS) {
38
+ const value = params[field];
39
+
40
+ if (typeof value !== 'string') {
41
+ continue;
42
+ }
43
+
44
+ const parsed = parseGatewayID(value, registry);
45
+
46
+ if (parsed === null || (daemon !== null && parsed.daemon !== daemon)) {
47
+ throw kind === 'session'
48
+ ? new DaemonError('no_such_session', `no session '${value}'`)
49
+ : new DaemonError('bad_args', `no ${kind} '${value}'`);
50
+ }
51
+
52
+ daemon = parsed.daemon;
53
+ rewritten[field] = parsed.id;
54
+
55
+ requestIDs.set(parsed.id, value);
56
+ }
57
+
58
+ return { daemon, params: rewritten, requestIDs };
59
+ }