@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,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,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
|
+
}
|