@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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +5 -0
- package/src/agents/gateway-adapter.ts +25 -2
- package/src/cli.ts +187 -113
- package/src/client/collect-agent-picks.ts +5 -2
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-agent-list.ts +8 -6
- package/src/daemon/build-auth-binding.ts +58 -0
- package/src/daemon/build-imp-name.ts +11 -0
- package/src/daemon/build-imp-provider.ts +25 -2
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +277 -20
- package/src/daemon/daemon-context.ts +8 -0
- package/src/daemon/daemon.ts +186 -32
- package/src/daemon/find-token-fingerprint.ts +23 -0
- package/src/daemon/handshake-throttle.ts +47 -0
- package/src/daemon/idempotency-ledger.ts +20 -2
- package/src/daemon/imp-port.ts +4 -2
- package/src/daemon/imp-provider.ts +24 -19
- package/src/daemon/is-allowed-listen-host.ts +63 -0
- package/src/daemon/load-listener-tokens.ts +46 -0
- package/src/daemon/parse-listen-address.ts +33 -0
- package/src/daemon/restore-fleet.ts +2 -1
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +165 -0
- package/src/federation/build-binding-payload-hash.ts +34 -0
- package/src/federation/build-daemon-outdated-error.ts +14 -0
- package/src/federation/build-events-filter-hash.ts +16 -0
- package/src/federation/build-gateway-error.ts +45 -0
- package/src/federation/build-gateway-id.ts +14 -0
- package/src/federation/build-gateway-result.ts +31 -0
- package/src/federation/build-ruled-value.ts +53 -0
- package/src/federation/collect-unruled-id-paths.ts +46 -0
- package/src/federation/daemon-caller.ts +473 -0
- package/src/federation/daemon-pool.ts +56 -0
- package/src/federation/decode-gateway-cursor.ts +73 -0
- package/src/federation/encode-gateway-cursor.ts +15 -0
- package/src/federation/gateway-error.ts +25 -0
- package/src/federation/gateway-store.ts +253 -0
- package/src/federation/id-rules.ts +85 -0
- package/src/federation/load-gateway-registry.ts +29 -0
- package/src/federation/max-events-cursor-bytes.ts +4 -0
- package/src/federation/max-registry-daemons.ts +26 -0
- package/src/federation/merge-event-pages.ts +228 -0
- package/src/federation/open-gateway-caller.ts +55 -0
- package/src/federation/parse-gateway-id.ts +28 -0
- package/src/federation/parse-gateway-registry.ts +123 -0
- package/src/federation/pick-daemon-state.ts +46 -0
- package/src/federation/plan-event-reads.ts +54 -0
- package/src/federation/read-fleet-events.ts +279 -0
- package/src/federation/require-serving-daemon.ts +27 -0
- package/src/federation/resolve-daemon-request.ts +59 -0
- package/src/federation/routing-caller.ts +450 -0
- package/src/federation/types.ts +33 -0
- package/src/federation/wait-for-outcome.ts +38 -0
- package/src/mcp/answer-rpc-request.ts +14 -1
- package/src/mcp/build-tool-list.ts +6 -5
- package/src/mcp/mcp-tools.ts +41 -8
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/mcp/run-tool.ts +15 -1
- package/src/mcp/start-mcp-http-server.ts +53 -9
- package/src/mcp/types.ts +8 -1
- package/src/protocol/daemon-features.ts +9 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/collect-auth-profiles.ts +122 -0
- package/src/shared/collect-gateways.ts +223 -6
- package/src/shared/config.ts +32 -4
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/shared/resolve-auth-profiles.ts +186 -0
- package/src/store/run-migrations.ts +77 -0
- package/src/store/runtime-auth-binding.ts +110 -0
- package/src/store/state-store.ts +202 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
|
+
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
4
|
+
import { buildDaemonOutdatedError } from './build-daemon-outdated-error';
|
|
5
|
+
import { GatewayError } from './gateway-error';
|
|
6
|
+
import type { RegistryDaemon } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One open protocol connection to a daemon, as the caller drives it:
|
|
10
|
+
* correlated requests, a callback for when the connection ends, and closing
|
|
11
|
+
* it. A request that rejects after the callback fired got no response.
|
|
12
|
+
*/
|
|
13
|
+
export interface GatewayChannel {
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
readonly sendRequest: (
|
|
16
|
+
m: string,
|
|
17
|
+
p?: Readonly<Record<string, unknown>>,
|
|
18
|
+
as?: string,
|
|
19
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
20
|
+
readonly stop: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a daemon's handshake told the gateway: its build, its state
|
|
25
|
+
* identity, the features it serves, and how long it keeps a completed
|
|
26
|
+
* idempotency key, null when it announced none.
|
|
27
|
+
*/
|
|
28
|
+
export interface DaemonHello {
|
|
29
|
+
readonly build: string;
|
|
30
|
+
readonly daemonID: string;
|
|
31
|
+
readonly features: ReadonlySet<DaemonFeature>;
|
|
32
|
+
readonly retentionMs: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DaemonCallerOptions {
|
|
36
|
+
readonly daemon: RegistryDaemon;
|
|
37
|
+
readonly build: string;
|
|
38
|
+
|
|
39
|
+
// Connects to the daemon's TCP address.
|
|
40
|
+
readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
|
|
41
|
+
|
|
42
|
+
// How long the connect and the handshake may take together, and how long
|
|
43
|
+
// a sent request may wait for its response.
|
|
44
|
+
readonly connectTimeoutMs?: number;
|
|
45
|
+
readonly responseTimeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// One handshaken connection and what its handshake returned.
|
|
49
|
+
interface OpenConnection {
|
|
50
|
+
readonly channel: GatewayChannel;
|
|
51
|
+
readonly hello: DaemonHello;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turns a request's params into the params one write of it sends, right
|
|
56
|
+
* before that write.
|
|
57
|
+
*/
|
|
58
|
+
export type ParamsPreparer = (
|
|
59
|
+
params: Readonly<Record<string, unknown>>,
|
|
60
|
+
) => Readonly<Record<string, unknown>>;
|
|
61
|
+
|
|
62
|
+
// The requests that only read, so a second run is harmless.
|
|
63
|
+
const READ_ONLY_METHODS: ReadonlySet<string> = new Set([
|
|
64
|
+
'agents.list',
|
|
65
|
+
'dirs.list',
|
|
66
|
+
'events.read',
|
|
67
|
+
'message.get',
|
|
68
|
+
'session.get',
|
|
69
|
+
'session.list',
|
|
70
|
+
'session.read',
|
|
71
|
+
'session.resumeCommand',
|
|
72
|
+
'session.screen',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// The requests a daemon runs at most once under an idempotency key, and the
|
|
76
|
+
// feature a daemon announces when it does.
|
|
77
|
+
const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, DaemonFeature>([
|
|
78
|
+
['session.spawn', 'spawn.idempotency'],
|
|
79
|
+
['session.message', 'message.idempotency'],
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
83
|
+
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The gateway's caller for one named daemon: it dials the daemon's TCP
|
|
87
|
+
* address with the daemon's bearer token, keeps one connection open, and
|
|
88
|
+
* sends nothing to a daemon whose handshake returns another `daemonID`
|
|
89
|
+
* than the registry pins. A failure before the request leaves is
|
|
90
|
+
* `daemon_unavailable`, or `daemon_unauthorized` for a refused token. A
|
|
91
|
+
* request sent whose response never arrives, because the connection ended
|
|
92
|
+
* or 30 s passed beyond the request's own `waitMs`, is never a failure: a
|
|
93
|
+
* keyed or read-only request is sent once more on a fresh connection to
|
|
94
|
+
* the same daemon. A keyed retry goes out replay-only, under the same key,
|
|
95
|
+
* and only while that connection announces both the key's feature and
|
|
96
|
+
* replay-only requests; a daemon that holds no such key answers it
|
|
97
|
+
* `idempotency_key_unknown`, which ends as `outcome_unknown`.
|
|
98
|
+
* Any other request, a reconnect without that feature, or a second loss is
|
|
99
|
+
* `outcome_unknown`. A daemon's
|
|
100
|
+
* own error passes through as it came. `prepareParams` runs right before
|
|
101
|
+
* each write of the request, first send and retry alike, and returns the
|
|
102
|
+
* params that go out; params it marks `replayOnly` never reach a connection
|
|
103
|
+
* that does not announce replay-only requests, which ends as
|
|
104
|
+
* `outcome_unknown` with nothing sent.
|
|
105
|
+
*/
|
|
106
|
+
export class DaemonCaller {
|
|
107
|
+
private readonly opts: DaemonCallerOptions;
|
|
108
|
+
|
|
109
|
+
private connection: Promise<OpenConnection> | null = null;
|
|
110
|
+
|
|
111
|
+
// The channel of the connection later requests reuse, once it is open.
|
|
112
|
+
private current: GatewayChannel | null = null;
|
|
113
|
+
|
|
114
|
+
private readonly closed = new WeakSet<GatewayChannel>();
|
|
115
|
+
|
|
116
|
+
constructor(opts: DaemonCallerOptions) {
|
|
117
|
+
this.opts = opts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async sendRequest(
|
|
121
|
+
m: string,
|
|
122
|
+
p: Readonly<Record<string, unknown>> = {},
|
|
123
|
+
as?: string,
|
|
124
|
+
required: readonly DaemonFeature[] = [],
|
|
125
|
+
prepareParams: ParamsPreparer = (params) => params,
|
|
126
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
127
|
+
// A request that acts as a principal relies on the daemon honouring it.
|
|
128
|
+
const needed: readonly DaemonFeature[] =
|
|
129
|
+
as === undefined ? required : [...required, 'request.principal'];
|
|
130
|
+
|
|
131
|
+
const opened = await this.openConnection();
|
|
132
|
+
|
|
133
|
+
const unserved = needed.find((feature) => !opened.hello.features.has(feature));
|
|
134
|
+
|
|
135
|
+
if (unserved !== undefined) {
|
|
136
|
+
throw buildDaemonOutdatedError(this.opts.daemon.name, unserved);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const first = await this.trySend(
|
|
140
|
+
opened.channel,
|
|
141
|
+
opened.hello.features,
|
|
142
|
+
m,
|
|
143
|
+
p,
|
|
144
|
+
as,
|
|
145
|
+
prepareParams,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (first.kind === 'answered') {
|
|
149
|
+
return first.ok;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const keyFeature = typeof p['idempotencyKey'] === 'string' ? KEYED_METHODS.get(m) : undefined;
|
|
153
|
+
const repeatable = READ_ONLY_METHODS.has(m) || keyFeature !== undefined;
|
|
154
|
+
|
|
155
|
+
if (!repeatable) {
|
|
156
|
+
throw this.buildOutcomeUnknown(m);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let fresh: OpenConnection;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
fresh = await this.openConnection();
|
|
163
|
+
} catch {
|
|
164
|
+
throw this.buildOutcomeUnknown(m);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A keyed retry only ever replays: it goes out replay-only, and only to
|
|
168
|
+
// a daemon that announces it takes the key and replay-only requests, so
|
|
169
|
+
// a daemon whose first send never arrived, or that has dropped the key
|
|
170
|
+
// since, runs nothing a second time.
|
|
171
|
+
const replays =
|
|
172
|
+
keyFeature !== undefined &&
|
|
173
|
+
fresh.hello.features.has(keyFeature) &&
|
|
174
|
+
fresh.hello.features.has('idempotency.replayOnly');
|
|
175
|
+
|
|
176
|
+
if (keyFeature !== undefined && !replays) {
|
|
177
|
+
throw this.buildOutcomeUnknown(m);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Nor does a retry reach a connection whose daemon would ignore what
|
|
181
|
+
// the request relies on, such as the principal it acts as.
|
|
182
|
+
if (needed.some((feature) => !fresh.hello.features.has(feature))) {
|
|
183
|
+
throw this.buildOutcomeUnknown(m);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const second = await this.trySend(fresh.channel, fresh.hello.features, m, p, as, (params) =>
|
|
187
|
+
replays ? { ...prepareParams(params), replayOnly: true } : prepareParams(params),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
if (second.kind === 'answered') {
|
|
191
|
+
return second.ok;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
throw this.buildOutcomeUnknown(m);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The handshake of the connection the next request rides, opening one
|
|
199
|
+
* when none is open.
|
|
200
|
+
*/
|
|
201
|
+
async readHello(): Promise<DaemonHello> {
|
|
202
|
+
const opened = await this.openConnection();
|
|
203
|
+
|
|
204
|
+
return opened.hello;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async stop(): Promise<void> {
|
|
208
|
+
const current = this.connection;
|
|
209
|
+
|
|
210
|
+
this.connection = null;
|
|
211
|
+
|
|
212
|
+
if (current === null) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const opened = await current;
|
|
218
|
+
|
|
219
|
+
opened.channel.stop();
|
|
220
|
+
} catch {
|
|
221
|
+
// A connection that never opened has nothing to close.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Sends one request, answering with the daemon's answer, or with `lost`
|
|
226
|
+
// when no response arrived. A daemon's error rejects as it came, except
|
|
227
|
+
// `idempotency_key_unknown`, which a replay-only request gets for a key
|
|
228
|
+
// the daemon never held or has dropped, and which ends as
|
|
229
|
+
// `outcome_unknown`.
|
|
230
|
+
private async trySend(
|
|
231
|
+
channel: Readonly<GatewayChannel>,
|
|
232
|
+
features: DaemonHello['features'],
|
|
233
|
+
m: string,
|
|
234
|
+
p: Readonly<Record<string, unknown>>,
|
|
235
|
+
as: string | undefined,
|
|
236
|
+
prepareParams: ParamsPreparer,
|
|
237
|
+
): Promise<
|
|
238
|
+
| { readonly kind: 'answered'; readonly ok: Readonly<Record<string, unknown>> }
|
|
239
|
+
| { readonly kind: 'lost' }
|
|
240
|
+
> {
|
|
241
|
+
// Runs in the same synchronous step as the write below, after the
|
|
242
|
+
// connection's handshake, so whatever it decides still holds when the
|
|
243
|
+
// request leaves.
|
|
244
|
+
const sent = prepareParams(p);
|
|
245
|
+
|
|
246
|
+
if (sent['replayOnly'] === true && !features.has('idempotency.replayOnly')) {
|
|
247
|
+
throw this.buildOutcomeUnknown(m);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const timeout = Promise.withResolvers<'timeout'>();
|
|
251
|
+
|
|
252
|
+
// A request that waits for a change on the daemon, such as a long poll,
|
|
253
|
+
// gets its own wait on top of the response time.
|
|
254
|
+
const waitMs = typeof sent['waitMs'] === 'number' && sent['waitMs'] > 0 ? sent['waitMs'] : 0;
|
|
255
|
+
|
|
256
|
+
const timer = setTimeout(
|
|
257
|
+
() => {
|
|
258
|
+
timeout.resolve('timeout');
|
|
259
|
+
},
|
|
260
|
+
(this.opts.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS) + waitMs,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Settles as a value either way, so a response that rejects after the
|
|
264
|
+
// timeout won never goes unhandled.
|
|
265
|
+
const settled = (async () => {
|
|
266
|
+
try {
|
|
267
|
+
return { kind: 'ok' as const, ok: await channel.sendRequest(m, sent, as) };
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return { kind: 'error' as const, error };
|
|
270
|
+
}
|
|
271
|
+
})();
|
|
272
|
+
|
|
273
|
+
const raced = await Promise.race([settled, timeout.promise]);
|
|
274
|
+
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
|
|
277
|
+
if (raced === 'timeout') {
|
|
278
|
+
this.closed.add(channel);
|
|
279
|
+
this.resetCurrentConnection(channel);
|
|
280
|
+
channel.stop();
|
|
281
|
+
|
|
282
|
+
return { kind: 'lost' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (raced.kind === 'ok') {
|
|
286
|
+
return { kind: 'answered', ok: raced.ok };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (this.closed.has(channel)) {
|
|
290
|
+
return { kind: 'lost' };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (raced.error instanceof DaemonError && raced.error.code === 'idempotency_key_unknown') {
|
|
294
|
+
throw this.buildOutcomeUnknown(m);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
throw raced.error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Stops later requests from reusing the channel's connection, which a
|
|
301
|
+
// timed-out request leaves with a response that may still arrive.
|
|
302
|
+
private resetCurrentConnection(channel: Readonly<GatewayChannel>): void {
|
|
303
|
+
if (this.current === channel) {
|
|
304
|
+
this.current = null;
|
|
305
|
+
this.connection = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private buildOutcomeUnknown(m: string): DaemonError {
|
|
310
|
+
return new DaemonError(
|
|
311
|
+
'outcome_unknown',
|
|
312
|
+
`daemon '${this.opts.daemon.name}' never answered ${m}; check what it did before sending it again`,
|
|
313
|
+
{ daemon: this.opts.daemon.name },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private openConnection(): Promise<OpenConnection> {
|
|
318
|
+
if (this.connection === null) {
|
|
319
|
+
const opening: Promise<OpenConnection> = this.openFreshConnection(
|
|
320
|
+
() => this.connection === opening,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
this.connection = opening;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return this.connection;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// isCurrent returns true while this connection is the one later requests
|
|
330
|
+
// reuse: a connection that ends after a newer one replaced it leaves the
|
|
331
|
+
// newer one in place.
|
|
332
|
+
private async openFreshConnection(isCurrent: () => boolean): Promise<OpenConnection> {
|
|
333
|
+
const daemon = this.opts.daemon;
|
|
334
|
+
|
|
335
|
+
const resetConnection = () => {
|
|
336
|
+
if (isCurrent()) {
|
|
337
|
+
this.connection = null;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const timeout = Promise.withResolvers<'timeout'>();
|
|
342
|
+
|
|
343
|
+
const timer = setTimeout(() => {
|
|
344
|
+
timeout.resolve('timeout');
|
|
345
|
+
}, this.opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS);
|
|
346
|
+
|
|
347
|
+
let timedOut = false;
|
|
348
|
+
|
|
349
|
+
// Settles as a value either way, and stops a connection that opens
|
|
350
|
+
// after the timeout won, so a late open never leaks.
|
|
351
|
+
const handshaken = (async () => {
|
|
352
|
+
try {
|
|
353
|
+
const late = await this.openHandshaken();
|
|
354
|
+
|
|
355
|
+
if (timedOut) {
|
|
356
|
+
late.channel.stop();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { kind: 'opened' as const, opened: late };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return { kind: 'failed' as const, error };
|
|
362
|
+
}
|
|
363
|
+
})();
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const raced = await Promise.race([handshaken, timeout.promise]);
|
|
367
|
+
|
|
368
|
+
if (raced === 'timeout') {
|
|
369
|
+
timedOut = true;
|
|
370
|
+
|
|
371
|
+
throw new GatewayError(
|
|
372
|
+
'daemon_unavailable',
|
|
373
|
+
`daemon '${daemon.name}' did not answer the connection in time`,
|
|
374
|
+
{ daemon: daemon.name },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (raced.kind === 'failed') {
|
|
379
|
+
throw raced.error;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const opened = raced.opened;
|
|
383
|
+
|
|
384
|
+
opened.channel.onClose = () => {
|
|
385
|
+
this.closed.add(opened.channel);
|
|
386
|
+
|
|
387
|
+
resetConnection();
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (isCurrent()) {
|
|
391
|
+
this.current = opened.channel;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return opened;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
resetConnection();
|
|
397
|
+
throw error;
|
|
398
|
+
} finally {
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Connects and handshakes, refusing a daemon behind another state
|
|
404
|
+
// identity than the pin before anything is sent to it.
|
|
405
|
+
private async openHandshaken(): Promise<OpenConnection> {
|
|
406
|
+
const daemon = this.opts.daemon;
|
|
407
|
+
let channel: GatewayChannel;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
channel = await this.opts.openChannel(daemon.address);
|
|
411
|
+
} catch {
|
|
412
|
+
throw new GatewayError('daemon_unavailable', `daemon '${daemon.name}' is unreachable`, {
|
|
413
|
+
daemon: daemon.name,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let answer: Readonly<Record<string, unknown>>;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
answer = await channel.sendRequest('daemon.hello', {
|
|
421
|
+
client: this.opts.build,
|
|
422
|
+
auth: { scheme: 'bearer', token: daemon.token },
|
|
423
|
+
});
|
|
424
|
+
} catch (error) {
|
|
425
|
+
channel.stop();
|
|
426
|
+
|
|
427
|
+
if (error instanceof DaemonError && error.code === 'unauthorized') {
|
|
428
|
+
throw new GatewayError(
|
|
429
|
+
'daemon_unauthorized',
|
|
430
|
+
`daemon '${daemon.name}' refused the gateway's token`,
|
|
431
|
+
{ daemon: daemon.name },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
throw new GatewayError(
|
|
436
|
+
'daemon_unavailable',
|
|
437
|
+
`daemon '${daemon.name}' did not complete the handshake`,
|
|
438
|
+
{ daemon: daemon.name },
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (answer['daemonID'] !== daemon.daemonID) {
|
|
443
|
+
channel.stop();
|
|
444
|
+
|
|
445
|
+
throw new GatewayError(
|
|
446
|
+
'daemon_unavailable',
|
|
447
|
+
`daemon '${daemon.name}' answers with another state identity than the registry pins`,
|
|
448
|
+
{ daemon: daemon.name, reason: 'daemon_changed' },
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { channel, hello: buildDaemonHello(answer, daemon.daemonID) };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function buildDaemonHello(
|
|
457
|
+
answer: Readonly<Record<string, unknown>>,
|
|
458
|
+
daemonID: string,
|
|
459
|
+
): DaemonHello {
|
|
460
|
+
const idempotency = answer['idempotency'];
|
|
461
|
+
|
|
462
|
+
const retention: unknown =
|
|
463
|
+
typeof idempotency === 'object' && idempotency !== null
|
|
464
|
+
? Reflect.get(idempotency, 'completedRetentionMs')
|
|
465
|
+
: undefined;
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
build: typeof answer['daemon'] === 'string' ? answer['daemon'] : 'unknown',
|
|
469
|
+
daemonID,
|
|
470
|
+
features: parseDaemonFeatures(answer),
|
|
471
|
+
retentionMs: typeof retention === 'number' && retention > 0 ? retention : null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DaemonCaller } from './daemon-caller';
|
|
2
|
+
import type { GatewayChannel } from './daemon-caller';
|
|
3
|
+
import type { GatewayRegistry, RegistryDaemon } from './types';
|
|
4
|
+
|
|
5
|
+
interface DaemonPoolOptions {
|
|
6
|
+
readonly registry: GatewayRegistry;
|
|
7
|
+
readonly build: string;
|
|
8
|
+
readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
|
|
9
|
+
readonly connectTimeoutMs?: number;
|
|
10
|
+
readonly responseTimeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One caller per registry daemon, each with its own connection, token, and
|
|
15
|
+
* pin, so a call routed to one daemon never rides another's connection.
|
|
16
|
+
*/
|
|
17
|
+
export class DaemonPool {
|
|
18
|
+
private readonly callers: ReadonlyMap<string, DaemonCaller>;
|
|
19
|
+
|
|
20
|
+
constructor(opts: DaemonPoolOptions) {
|
|
21
|
+
this.callers = new Map(
|
|
22
|
+
[...opts.registry.daemons.values()].map((daemon) => [
|
|
23
|
+
daemon.name,
|
|
24
|
+
new DaemonCaller({
|
|
25
|
+
daemon,
|
|
26
|
+
build: opts.build,
|
|
27
|
+
openChannel: opts.openChannel,
|
|
28
|
+
...(opts.connectTimeoutMs === undefined
|
|
29
|
+
? {}
|
|
30
|
+
: { connectTimeoutMs: opts.connectTimeoutMs }),
|
|
31
|
+
...(opts.responseTimeoutMs === undefined
|
|
32
|
+
? {}
|
|
33
|
+
: { responseTimeoutMs: opts.responseTimeoutMs }),
|
|
34
|
+
}),
|
|
35
|
+
]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The caller of a registry daemon. Throws for a name the registry does
|
|
41
|
+
* not hold, which a route resolved through the registry never gives.
|
|
42
|
+
*/
|
|
43
|
+
getCaller(name: string): DaemonCaller {
|
|
44
|
+
const caller = this.callers.get(name);
|
|
45
|
+
|
|
46
|
+
if (caller === undefined) {
|
|
47
|
+
throw new Error(`no daemon '${name}' in the registry`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return caller;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async stop(): Promise<void> {
|
|
54
|
+
await Promise.all([...this.callers.values()].map((caller) => caller.stop()));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
import { MAX_EVENTS_CURSOR_BYTES } from './max-events-cursor-bytes';
|
|
4
|
+
import type { GatewayRegistry } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Each registry daemon's position in a gateway events cursor, by daemon
|
|
8
|
+
* name: its own cursor, or null for a daemon that has not answered since the
|
|
9
|
+
* read that started the cursor. A daemon the cursor leaves out is absent. The gateway refuses with `bad_args` a
|
|
10
|
+
* cursor over 4 KiB, one it cannot decode, one with a version other than
|
|
11
|
+
* 1, one read under another filter, and one whose part for a daemon holds
|
|
12
|
+
* another incarnation than the registry pins. A part for a name the
|
|
13
|
+
* registry no longer lists is dropped.
|
|
14
|
+
*/
|
|
15
|
+
export function decodeGatewayCursor(
|
|
16
|
+
raw: string,
|
|
17
|
+
filter: string,
|
|
18
|
+
registry: GatewayRegistry,
|
|
19
|
+
): ReadonlyMap<string, string | null> {
|
|
20
|
+
if (Buffer.byteLength(raw) > MAX_EVENTS_CURSOR_BYTES) {
|
|
21
|
+
throw new DaemonError('bad_args', `cursor exceeds ${MAX_EVENTS_CURSOR_BYTES} bytes`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let wire: unknown;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
wire = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isRecord(wire) || !isRecord(wire['daemons'])) {
|
|
33
|
+
throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (wire['v'] !== 1) {
|
|
37
|
+
throw new DaemonError('bad_args', 'the events cursor has a version this gateway does not read');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (wire['filter'] !== filter) {
|
|
41
|
+
throw new DaemonError('bad_args', 'the events cursor was read under other filters');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parts = new Map<string, string | null>();
|
|
45
|
+
|
|
46
|
+
for (const [key, position] of Object.entries(wire['daemons'])) {
|
|
47
|
+
const dot = key.indexOf('.');
|
|
48
|
+
|
|
49
|
+
if (dot === -1) {
|
|
50
|
+
throw new DaemonError('bad_args', `'${raw}' is not an events cursor`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const daemon = registry.daemons.get(key.slice(0, dot));
|
|
54
|
+
|
|
55
|
+
if (daemon === undefined) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
key.slice(dot + 1) !== daemon.incarnation ||
|
|
61
|
+
(typeof position !== 'string' && position !== null)
|
|
62
|
+
) {
|
|
63
|
+
throw new DaemonError(
|
|
64
|
+
'bad_args',
|
|
65
|
+
`the events cursor holds a stale position for daemon '${daemon.name}'`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
parts.set(daemon.name, position);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return parts;
|
|
73
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A gateway events cursor: base64url JSON of `{ v: 1, filter, daemons }`,
|
|
3
|
+
* where `daemons` maps `<name>.<incarnation>` to that daemon's own cursor,
|
|
4
|
+
* always a concrete position, or to null for a daemon that has not answered
|
|
5
|
+
* since the read that started the cursor. The decoder refuses a cursor
|
|
6
|
+
* whose version, filter, or incarnations do not match.
|
|
7
|
+
*/
|
|
8
|
+
export function encodeGatewayCursor(
|
|
9
|
+
filter: string,
|
|
10
|
+
daemons: ReadonlyMap<string, string | null>,
|
|
11
|
+
): string {
|
|
12
|
+
const wire = { v: 1, filter, daemons: Object.fromEntries(daemons) };
|
|
13
|
+
|
|
14
|
+
return Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A refusal the gateway makes about a daemon itself, as opposed to an
|
|
3
|
+
* answer a daemon gave: `daemon_unavailable` when the daemon never received
|
|
4
|
+
* the request, `daemon_unauthorized` when it refused the gateway's token,
|
|
5
|
+
* and `daemon_outdated` when it lacks a feature the request needs. `data`
|
|
6
|
+
* holds the daemon's name, and for a pin mismatch the reason
|
|
7
|
+
* `daemon_changed`.
|
|
8
|
+
*/
|
|
9
|
+
export class GatewayError extends Error {
|
|
10
|
+
readonly code: 'daemon_unavailable' | 'daemon_unauthorized' | 'daemon_outdated';
|
|
11
|
+
|
|
12
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
code: 'daemon_unavailable' | 'daemon_unauthorized' | 'daemon_outdated',
|
|
16
|
+
msg: string,
|
|
17
|
+
data: Readonly<Record<string, unknown>>,
|
|
18
|
+
) {
|
|
19
|
+
super(msg);
|
|
20
|
+
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.data = data;
|
|
23
|
+
this.name = 'GatewayError';
|
|
24
|
+
}
|
|
25
|
+
}
|