@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.
- package/package.json +1 -1
- package/src/cli.ts +187 -118
- package/src/client/daemon-client.ts +29 -18
- package/src/daemon/build-payload-hash.ts +4 -3
- package/src/daemon/daemon-connection.ts +270 -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/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/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 +1 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/run-daemon-id.ts +52 -0
- package/src/shared/find-daemon-record.ts +8 -3
- package/src/store/state-store.ts +16 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
4
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
5
|
+
import { buildBindingPayloadHash } from './build-binding-payload-hash';
|
|
6
|
+
import { buildGatewayError } from './build-gateway-error';
|
|
7
|
+
import { buildGatewayResult } from './build-gateway-result';
|
|
8
|
+
import type { DaemonCaller, ParamsPreparer } from './daemon-caller';
|
|
9
|
+
import type { DaemonPool } from './daemon-pool';
|
|
10
|
+
import { GatewayError } from './gateway-error';
|
|
11
|
+
import type { GatewayStore } from './gateway-store';
|
|
12
|
+
import { pickDaemonState } from './pick-daemon-state';
|
|
13
|
+
import { readFleetEvents } from './read-fleet-events';
|
|
14
|
+
import { requireServingDaemon } from './require-serving-daemon';
|
|
15
|
+
import { resolveDaemonRequest } from './resolve-daemon-request';
|
|
16
|
+
import type { GatewayFeature, GatewayRegistry, RegistryDaemon } from './types';
|
|
17
|
+
import { waitForOutcome } from './wait-for-outcome';
|
|
18
|
+
import type { CallOutcome } from './wait-for-outcome';
|
|
19
|
+
|
|
20
|
+
interface RoutingCallerOptions {
|
|
21
|
+
readonly registry: GatewayRegistry;
|
|
22
|
+
readonly pool: DaemonPool;
|
|
23
|
+
readonly store: GatewayStore;
|
|
24
|
+
|
|
25
|
+
// How long each daemon may take to answer a call the gateway asks every
|
|
26
|
+
// daemon; 5 s when unset.
|
|
27
|
+
readonly fanOutTimeoutMs?: number;
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FAN_OUT_TIMEOUT_MS = 5000;
|
|
32
|
+
|
|
33
|
+
// The requests a daemon runs at most once under an idempotency key.
|
|
34
|
+
const KEYED_METHODS: ReadonlySet<string> = new Set(['session.spawn', 'session.message']);
|
|
35
|
+
|
|
36
|
+
// One routed request as it leaves for its daemon; `key` is null for an
|
|
37
|
+
// unkeyed request.
|
|
38
|
+
interface RoutedSend {
|
|
39
|
+
readonly m: string;
|
|
40
|
+
readonly route: ReturnType<typeof resolveDaemonRequest>;
|
|
41
|
+
readonly daemon: RegistryDaemon;
|
|
42
|
+
readonly required: readonly DaemonFeature[];
|
|
43
|
+
readonly principal: string;
|
|
44
|
+
readonly key: string | null;
|
|
45
|
+
|
|
46
|
+
// Picks the params of each write of the request, right before it.
|
|
47
|
+
readonly prepareParams?: ParamsPreparer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A keyed request on its way to a daemon: the daemon its ids or the
|
|
51
|
+
// default picked, and the one its `daemon` param named, if any.
|
|
52
|
+
interface KeyedRoute {
|
|
53
|
+
readonly m: string;
|
|
54
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
55
|
+
readonly key: string;
|
|
56
|
+
readonly picked: RegistryDaemon;
|
|
57
|
+
readonly named: RegistryDaemon | null;
|
|
58
|
+
readonly required: readonly DaemonFeature[];
|
|
59
|
+
readonly principal: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The gateway's caller for the MCP tools, the same shape as the caller the
|
|
64
|
+
* tool handlers take: each request goes to the one
|
|
65
|
+
* daemon its ids, its `daemon` param, or the registry's default daemon
|
|
66
|
+
* picks, in that order, and never to another daemon when that one fails.
|
|
67
|
+
* A request always acts as the principal it is given, which the HTTP layer
|
|
68
|
+
* takes from the verified OAuth client id, and a request without one never
|
|
69
|
+
* leaves. A keyed spawn or message is bound to its daemon before it is
|
|
70
|
+
* sent, so a retry reaches the same daemon whatever the default is by
|
|
71
|
+
* then. `session.list`, `agents.list`, and `events.read` ask every daemon
|
|
72
|
+
* in parallel and report the daemons that did not answer; `daemons.list`
|
|
73
|
+
* answers from the gateway itself. Every id in an answer or a daemon's
|
|
74
|
+
* error comes back as a gateway id.
|
|
75
|
+
*/
|
|
76
|
+
export class RoutingCaller {
|
|
77
|
+
private readonly opts: RoutingCallerOptions;
|
|
78
|
+
|
|
79
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the options hold the live daemon pool and binding store
|
|
80
|
+
constructor(opts: RoutingCallerOptions) {
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sendRequest(
|
|
85
|
+
m: string,
|
|
86
|
+
p: Readonly<Record<string, unknown>> = {},
|
|
87
|
+
required: readonly DaemonFeature[] = [],
|
|
88
|
+
principal?: string,
|
|
89
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
90
|
+
if (principal === undefined) {
|
|
91
|
+
return Promise.reject(
|
|
92
|
+
new Error('the gateway sends a request only as the verified client of the call'),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { daemon: named, ...params } = p;
|
|
97
|
+
|
|
98
|
+
return match(m)
|
|
99
|
+
.with('daemons.list', () => this.readDaemonStates())
|
|
100
|
+
.with('session.list', 'agents.list', (fanned) => this.sendFanOut(fanned, required, principal))
|
|
101
|
+
.with('events.read', () =>
|
|
102
|
+
readFleetEvents(
|
|
103
|
+
{ registry: this.opts.registry, getCaller: this.getCaller, timeoutMs: this.getTimeout() },
|
|
104
|
+
params,
|
|
105
|
+
required,
|
|
106
|
+
principal,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
.otherwise(() => this.sendRouted(m, params, named, required, principal));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The features any daemon that is up announces, plus `fleet.daemons`, so
|
|
114
|
+
* the tool list offers an option when at least one daemon serves it and
|
|
115
|
+
* the inputs that pick a daemon always.
|
|
116
|
+
*/
|
|
117
|
+
async readFeatures(): Promise<ReadonlySet<GatewayFeature>> {
|
|
118
|
+
const outcomes = await Promise.all(
|
|
119
|
+
[...this.opts.registry.daemons.values()].map((daemon) =>
|
|
120
|
+
waitForOutcome(this.opts.pool.getCaller(daemon.name).readHello(), this.getTimeout()),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const features = new Set<GatewayFeature>(['fleet.daemons']);
|
|
125
|
+
|
|
126
|
+
for (const outcome of outcomes) {
|
|
127
|
+
if (outcome.kind === 'answered') {
|
|
128
|
+
for (const feature of outcome.value.features) {
|
|
129
|
+
features.add(feature);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return features;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private readonly getCaller = (name: string): DaemonCaller => this.opts.pool.getCaller(name);
|
|
138
|
+
|
|
139
|
+
private getTimeout(): number {
|
|
140
|
+
return this.opts.fanOutTimeoutMs ?? FAN_OUT_TIMEOUT_MS;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private getNow(): number {
|
|
144
|
+
return this.opts.now === undefined ? Date.now() : this.opts.now();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The daemon a `daemon` param picks, or null when the call gives none.
|
|
148
|
+
private findNamedDaemon(named: unknown): RegistryDaemon | null {
|
|
149
|
+
if (named === undefined) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const daemon = typeof named === 'string' ? this.opts.registry.daemons.get(named) : undefined;
|
|
154
|
+
|
|
155
|
+
if (daemon === undefined) {
|
|
156
|
+
const shown = typeof named === 'string' ? named : JSON.stringify(named);
|
|
157
|
+
|
|
158
|
+
throw new DaemonError('bad_args', `no daemon '${shown}' in this gateway`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return daemon;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private getDefaultDaemon(): RegistryDaemon {
|
|
165
|
+
const daemon = this.opts.registry.daemons.get(this.opts.registry.defaultDaemon);
|
|
166
|
+
|
|
167
|
+
if (daemon === undefined) {
|
|
168
|
+
throw new Error('the registry default daemon is missing from the registry');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return daemon;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private sendRouted(
|
|
175
|
+
m: string,
|
|
176
|
+
params: Readonly<Record<string, unknown>>,
|
|
177
|
+
rawNamed: unknown,
|
|
178
|
+
required: readonly DaemonFeature[],
|
|
179
|
+
principal: string,
|
|
180
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
181
|
+
const named = this.findNamedDaemon(rawNamed);
|
|
182
|
+
const route = resolveDaemonRequest(params, this.opts.registry);
|
|
183
|
+
|
|
184
|
+
if (route.daemon !== null && named !== null && route.daemon !== named) {
|
|
185
|
+
throw new DaemonError(
|
|
186
|
+
'bad_args',
|
|
187
|
+
`the call's ids belong to daemon '${route.daemon.name}', not '${named.name}'`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const picked = route.daemon ?? named ?? this.getDefaultDaemon();
|
|
192
|
+
const rawKey = route.params['idempotencyKey'];
|
|
193
|
+
const key = typeof rawKey === 'string' && KEYED_METHODS.has(m) ? rawKey : null;
|
|
194
|
+
|
|
195
|
+
if (key === null) {
|
|
196
|
+
return this.sendToRoute({ m, route, daemon: picked, required, principal, key: null });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const keyed: KeyedRoute = { m, params: route.params, key, picked, named, required, principal };
|
|
200
|
+
|
|
201
|
+
return this.sendKeyed(keyed, route);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Sends a keyed request to the daemon its binding holds. Only the first
|
|
205
|
+
// write under the key, across every call and gateway process, goes out
|
|
206
|
+
// as a normal request: right before each write the binding is marked
|
|
207
|
+
// sent, and a write that finds it sent already goes out replay-only, so
|
|
208
|
+
// the daemon answers it from the key it holds and runs nothing when it
|
|
209
|
+
// holds none. A gateway restart between the claim and the first write
|
|
210
|
+
// leaves the binding unsent, so the next call makes that first send;
|
|
211
|
+
// one after leaves it sent, so the next call replays. A refusal before
|
|
212
|
+
// anything is sent leaves no trace of the key.
|
|
213
|
+
private async sendKeyed(
|
|
214
|
+
keyed: KeyedRoute,
|
|
215
|
+
route: RoutedSend['route'],
|
|
216
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
217
|
+
const claimed = await this.claimRoute(keyed);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
return await this.sendToRoute({
|
|
221
|
+
m: keyed.m,
|
|
222
|
+
route,
|
|
223
|
+
daemon: claimed.daemon,
|
|
224
|
+
required: keyed.required,
|
|
225
|
+
principal: keyed.principal,
|
|
226
|
+
key: keyed.key,
|
|
227
|
+
prepareParams: (params) =>
|
|
228
|
+
this.opts.store.claimFirstSend(keyed.principal, keyed.m, keyed.key, this.getNow())
|
|
229
|
+
? params
|
|
230
|
+
: { ...params, replayOnly: true },
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof GatewayError) {
|
|
234
|
+
this.opts.store.removeBinding(keyed.principal, keyed.m, keyed.key, claimed.claimID);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Sends one routed request to its daemon on a connection whose handshake
|
|
242
|
+
// announces every feature the request relies on, and records a keyed
|
|
243
|
+
// request's outcome on its binding.
|
|
244
|
+
private async sendToRoute(send: RoutedSend): Promise<Readonly<Record<string, unknown>>> {
|
|
245
|
+
const store = this.opts.store;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const ok = await this.opts.pool
|
|
249
|
+
.getCaller(send.daemon.name)
|
|
250
|
+
.sendRequest(send.m, send.route.params, send.principal, send.required, send.prepareParams);
|
|
251
|
+
|
|
252
|
+
if (send.key !== null) {
|
|
253
|
+
store.updateOutcome(send.principal, send.m, send.key, 'completed', this.getNow());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return buildGatewayResult(send.m, ok, send.daemon);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (send.key !== null && error instanceof DaemonError) {
|
|
259
|
+
const outcome = error.code === 'outcome_unknown' ? 'uncertain' : 'completed';
|
|
260
|
+
const rawRef = error.data?.['effectRef'];
|
|
261
|
+
const effectRef = typeof rawRef === 'string' ? rawRef : null;
|
|
262
|
+
|
|
263
|
+
store.updateOutcome(send.principal, send.m, send.key, outcome, this.getNow(), effectRef);
|
|
264
|
+
|
|
265
|
+
const held = store.findBinding(send.principal, send.m, send.key);
|
|
266
|
+
|
|
267
|
+
// An unknown outcome carries the daemon's id of the effect when any
|
|
268
|
+
// answer under the key returned one.
|
|
269
|
+
if (
|
|
270
|
+
outcome === 'uncertain' &&
|
|
271
|
+
effectRef === null &&
|
|
272
|
+
held !== null &&
|
|
273
|
+
held.effectRef !== null
|
|
274
|
+
) {
|
|
275
|
+
const known = new DaemonError(error.code, error.message, {
|
|
276
|
+
...error.data,
|
|
277
|
+
effectRef: held.effectRef,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
throw buildGatewayError(known, send.daemon, send.route.requestIDs);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (error instanceof DaemonError) {
|
|
285
|
+
throw buildGatewayError(error, send.daemon, send.route.requestIDs);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The daemon a keyed request goes to: the one an earlier call with the
|
|
293
|
+
// same principal, operation, and key was bound to, or else the one the
|
|
294
|
+
// call picked, bound now, before anything is sent. A bound daemon whose
|
|
295
|
+
// state identity changed, or that left the registry, is unavailable; the
|
|
296
|
+
// key never goes to another daemon. Completed bindings past their
|
|
297
|
+
// retention go first.
|
|
298
|
+
private async claimRoute(
|
|
299
|
+
route: KeyedRoute,
|
|
300
|
+
): Promise<{ readonly daemon: RegistryDaemon; readonly claimID: string }> {
|
|
301
|
+
const store = this.opts.store;
|
|
302
|
+
const now = this.getNow();
|
|
303
|
+
|
|
304
|
+
store.removeExpiredBindings(now);
|
|
305
|
+
|
|
306
|
+
const held = store.findBinding(route.principal, route.m, route.key);
|
|
307
|
+
const bound = held === null ? null : this.opts.registry.daemons.get(held.daemon);
|
|
308
|
+
|
|
309
|
+
if (held !== null && (bound === undefined || bound?.daemonID !== held.daemonID)) {
|
|
310
|
+
throw new GatewayError(
|
|
311
|
+
'daemon_unavailable',
|
|
312
|
+
`daemon '${held.daemon}', which idempotency key '${route.key}' went to, now has another state identity`,
|
|
313
|
+
{ daemon: held.daemon, reason: 'daemon_changed' },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (held !== null && route.named !== null && route.named.name !== held.daemon) {
|
|
318
|
+
throw buildConflict(route.key, held.daemon);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const daemon = bound ?? route.picked;
|
|
322
|
+
|
|
323
|
+
const hello = await requireServingDaemon(this.getCaller, daemon, route.required);
|
|
324
|
+
|
|
325
|
+
const claimID = randomUUID();
|
|
326
|
+
|
|
327
|
+
const binding = store.claimBinding(
|
|
328
|
+
{
|
|
329
|
+
principal: route.principal,
|
|
330
|
+
operation: route.m,
|
|
331
|
+
key: route.key,
|
|
332
|
+
daemon: daemon.name,
|
|
333
|
+
daemonID: daemon.daemonID,
|
|
334
|
+
retentionMs: hello.retentionMs,
|
|
335
|
+
payloadHash: buildBindingPayloadHash(route.params),
|
|
336
|
+
claimID,
|
|
337
|
+
},
|
|
338
|
+
this.getNow(),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Another gateway call may have bound the key since it was read; the
|
|
342
|
+
// request goes only where the stored binding points.
|
|
343
|
+
if (binding.daemon !== daemon.name) {
|
|
344
|
+
throw buildConflict(route.key, binding.daemon);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { daemon, claimID };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Asks every daemon at once. `session.list` merges the sessions and adds
|
|
351
|
+
// each daemon's state, so a daemon that did not answer reads as down,
|
|
352
|
+
// never as one with no sessions; `agents.list` returns each daemon's
|
|
353
|
+
// answer, or its state alone, under its name.
|
|
354
|
+
private async sendFanOut(
|
|
355
|
+
m: 'session.list' | 'agents.list',
|
|
356
|
+
required: readonly DaemonFeature[],
|
|
357
|
+
principal: string,
|
|
358
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
359
|
+
const daemons = [...this.opts.registry.daemons.values()];
|
|
360
|
+
|
|
361
|
+
const outcomes = await Promise.all(
|
|
362
|
+
daemons.map((daemon) =>
|
|
363
|
+
waitForOutcome(this.sendToDaemon(m, daemon, required, principal), this.getTimeout()),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
const answered = daemons.map((daemon, index) => ({
|
|
368
|
+
daemon,
|
|
369
|
+
outcome: outcomes[index] ?? ({ kind: 'timeout' } as const),
|
|
370
|
+
}));
|
|
371
|
+
|
|
372
|
+
if (m === 'agents.list') {
|
|
373
|
+
return {
|
|
374
|
+
daemons: Object.fromEntries(
|
|
375
|
+
answered.map((entry) => [
|
|
376
|
+
entry.daemon.name,
|
|
377
|
+
entry.outcome.kind === 'answered'
|
|
378
|
+
? { state: 'up', ...entry.outcome.value }
|
|
379
|
+
: { state: pickDaemonState(entry.outcome) },
|
|
380
|
+
]),
|
|
381
|
+
),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
sessions: answered.flatMap((entry) => collectSessions(entry.outcome)),
|
|
387
|
+
daemons: answered.map((entry) => ({
|
|
388
|
+
name: entry.daemon.name,
|
|
389
|
+
state: pickDaemonState(entry.outcome),
|
|
390
|
+
})),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async sendToDaemon(
|
|
395
|
+
m: string,
|
|
396
|
+
daemon: RegistryDaemon,
|
|
397
|
+
required: readonly DaemonFeature[],
|
|
398
|
+
principal: string,
|
|
399
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
400
|
+
const ok = await this.opts.pool.getCaller(daemon.name).sendRequest(m, {}, principal, required);
|
|
401
|
+
|
|
402
|
+
return buildGatewayResult(m, ok, daemon);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Each daemon's name, state, build, pinned state identity, and features,
|
|
406
|
+
// read from its handshake, and the default daemon. Never an address or a
|
|
407
|
+
// token.
|
|
408
|
+
private async readDaemonStates(): Promise<Readonly<Record<string, unknown>>> {
|
|
409
|
+
const daemons = [...this.opts.registry.daemons.values()];
|
|
410
|
+
|
|
411
|
+
const outcomes = await Promise.all(
|
|
412
|
+
daemons.map((daemon) =>
|
|
413
|
+
waitForOutcome(this.opts.pool.getCaller(daemon.name).readHello(), this.getTimeout()),
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
daemons: daemons.map((daemon, index) => {
|
|
419
|
+
const outcome = outcomes[index] ?? ({ kind: 'timeout' } as const);
|
|
420
|
+
const hello = outcome.kind === 'answered' ? outcome.value : null;
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
name: daemon.name,
|
|
424
|
+
state: pickDaemonState(outcome),
|
|
425
|
+
build: hello?.build ?? null,
|
|
426
|
+
daemonID: daemon.daemonID,
|
|
427
|
+
features: hello === null ? [] : [...hello.features].toSorted(),
|
|
428
|
+
};
|
|
429
|
+
}),
|
|
430
|
+
defaultDaemon: this.opts.registry.defaultDaemon,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function collectSessions(outcome: CallOutcome<Readonly<Record<string, unknown>>>): unknown[] {
|
|
436
|
+
if (outcome.kind !== 'answered') {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const sessions: unknown = outcome.value['sessions'];
|
|
441
|
+
|
|
442
|
+
return Array.isArray(sessions) ? sessions : [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function buildConflict(key: string, daemon: string): DaemonError {
|
|
446
|
+
return new DaemonError(
|
|
447
|
+
'idempotency_conflict',
|
|
448
|
+
`idempotency key '${key}' was first used on daemon '${daemon}'`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One daemon the gateway routes to: its logical name, the TCP address it
|
|
5
|
+
* listens on, the state identity pinned behind the name, the incarnation
|
|
6
|
+
* that identity puts in every gateway id, and the bearer token the gateway
|
|
7
|
+
* presents to it.
|
|
8
|
+
*/
|
|
9
|
+
export interface RegistryDaemon {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly address: { readonly host: string; readonly port: number };
|
|
12
|
+
readonly daemonID: string;
|
|
13
|
+
|
|
14
|
+
// The first 8 hex digits of the pinned daemon ID.
|
|
15
|
+
readonly incarnation: string;
|
|
16
|
+
readonly token: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The daemons the gateway routes to, by name, and the one a spawn without
|
|
21
|
+
* a daemon goes to.
|
|
22
|
+
*/
|
|
23
|
+
export interface GatewayRegistry {
|
|
24
|
+
readonly daemons: ReadonlyMap<string, RegistryDaemon>;
|
|
25
|
+
readonly defaultDaemon: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A feature the gateway announces to the tool layer: every feature an up
|
|
30
|
+
* daemon announces, plus `fleet.daemons`, which marks a caller that routes
|
|
31
|
+
* across named daemons and offers the tools and inputs that pick one.
|
|
32
|
+
*/
|
|
33
|
+
export type GatewayFeature = DaemonFeature | 'fleet.daemons';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a call settled within its time: answered with a value, failed with
|
|
3
|
+
* an error, or still pending when the time ran out.
|
|
4
|
+
*/
|
|
5
|
+
export type CallOutcome<T> =
|
|
6
|
+
| { readonly kind: 'answered'; readonly value: T }
|
|
7
|
+
| { readonly kind: 'failed'; readonly error: unknown }
|
|
8
|
+
| { readonly kind: 'timeout' };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Waits for a call for at most `timeoutMs`, and never throws: a failure or
|
|
12
|
+
* a timeout comes back as a value. A call still pending at the timeout
|
|
13
|
+
* keeps running, and whatever it settles to later is dropped.
|
|
14
|
+
*/
|
|
15
|
+
export async function waitForOutcome<T>(
|
|
16
|
+
call: Readonly<Promise<T>>,
|
|
17
|
+
timeoutMs: number,
|
|
18
|
+
): Promise<CallOutcome<T>> {
|
|
19
|
+
const timeout = Promise.withResolvers<CallOutcome<T>>();
|
|
20
|
+
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
timeout.resolve({ kind: 'timeout' });
|
|
23
|
+
}, timeoutMs);
|
|
24
|
+
|
|
25
|
+
const settled = (async (): Promise<CallOutcome<T>> => {
|
|
26
|
+
try {
|
|
27
|
+
return { kind: 'answered', value: await call };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return { kind: 'failed', error };
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
const outcome = await Promise.race([settled, timeout.promise]);
|
|
34
|
+
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
|
|
37
|
+
return outcome;
|
|
38
|
+
}
|
|
@@ -143,8 +143,12 @@ async function answerToolCall(
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// A daemon's error, or any other error that holds a lowercase protocol-style
|
|
147
|
+
// code, such as a router's refusal about a daemon, reads as
|
|
148
|
+
// `<code>: <message>`. A system error's uppercase code is already in its
|
|
149
|
+
// message.
|
|
146
150
|
function formatToolError(error: unknown): string {
|
|
147
|
-
if (error instanceof DaemonError) {
|
|
151
|
+
if (error instanceof DaemonError || hasErrorCode(error)) {
|
|
148
152
|
return `${error.code}: ${error.message}`;
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -155,6 +159,15 @@ function formatToolError(error: unknown): string {
|
|
|
155
159
|
return String(error);
|
|
156
160
|
}
|
|
157
161
|
|
|
162
|
+
function hasErrorCode(error: unknown): error is Error & { readonly code: string } {
|
|
163
|
+
return (
|
|
164
|
+
error instanceof Error &&
|
|
165
|
+
'code' in error &&
|
|
166
|
+
typeof error.code === 'string' &&
|
|
167
|
+
/^[a-z][a-z_]*$/.test(error.code)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
158
171
|
function buildRPCResult(
|
|
159
172
|
id: string | number,
|
|
160
173
|
result: Readonly<Record<string, unknown>>,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
2
1
|
import { isRecord } from '../shared/report';
|
|
3
2
|
import { buildSpawnDescriptions } from './build-spawn-descriptions';
|
|
4
3
|
import type { RegisteredAgent } from './build-spawn-descriptions';
|
|
5
4
|
import { MCP_TOOLS } from './mcp-tools';
|
|
5
|
+
import type { FleetFeature } from './types';
|
|
6
6
|
|
|
7
7
|
interface MCPTool {
|
|
8
8
|
readonly name: string;
|
|
@@ -26,7 +26,7 @@ interface MCPTool {
|
|
|
26
26
|
* agents the host registers or installs.
|
|
27
27
|
*/
|
|
28
28
|
export function buildToolList(
|
|
29
|
-
features: ReadonlySet<
|
|
29
|
+
features: ReadonlySet<FleetFeature>,
|
|
30
30
|
agents: readonly RegisteredAgent[] | null,
|
|
31
31
|
): readonly MCPTool[] {
|
|
32
32
|
const spawn = buildSpawnDescriptions(agents);
|
|
@@ -45,7 +45,8 @@ export function buildToolList(
|
|
|
45
45
|
|
|
46
46
|
return [
|
|
47
47
|
tool.outputSchema === undefined ||
|
|
48
|
-
(requires.output !== undefined && !features.has(requires.output))
|
|
48
|
+
(requires.output !== undefined && !features.has(requires.output)) ||
|
|
49
|
+
(requires.outputUnless !== undefined && features.has(requires.outputUnless))
|
|
49
50
|
? {
|
|
50
51
|
name: tool.name,
|
|
51
52
|
description,
|
|
@@ -65,8 +66,8 @@ export function buildToolList(
|
|
|
65
66
|
|
|
66
67
|
function buildInputSchema(
|
|
67
68
|
schema: Readonly<Record<string, unknown>>,
|
|
68
|
-
inputs: Readonly<Record<string,
|
|
69
|
-
features: ReadonlySet<
|
|
69
|
+
inputs: Readonly<Record<string, FleetFeature>>,
|
|
70
|
+
features: ReadonlySet<FleetFeature>,
|
|
70
71
|
): Readonly<Record<string, unknown>> {
|
|
71
72
|
const withheld = Object.entries(inputs).flatMap(([name, feature]) =>
|
|
72
73
|
features.has(feature) ? [] : [name],
|