@zgeoff/atc 2.20.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/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,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
|
+
}
|