@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
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
2
|
import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
|
|
4
3
|
import type { GrantScope } from '../shared/grant-scope';
|
|
5
4
|
import { buildSpawnDescriptions } from './build-spawn-descriptions';
|
|
6
5
|
import { IDEMPOTENCY_KEY_FIELD } from './parse-idempotency-key';
|
|
6
|
+
import type { FleetFeature } from './types';
|
|
7
7
|
|
|
8
8
|
const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
|
|
9
9
|
|
|
@@ -27,8 +27,23 @@ const { $schema: _, ...IDEMPOTENCY_KEY_INPUT } = z.toJSONSchema(IDEMPOTENCY_KEY_
|
|
|
27
27
|
io: 'input',
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// The daemon a call goes to, offered only by a caller that routes across
|
|
31
|
+
// named daemons.
|
|
32
|
+
const DAEMON_FIELD = z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe(
|
|
36
|
+
'The atc daemon to run on, one of the names atc_daemons_list returns. Omit it to use the default daemon. A daemon that is down answers daemon_unavailable; atc never runs the call on another daemon instead.',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
40
|
+
z.strictObject({ daemon: DAEMON_FIELD }),
|
|
41
|
+
{ io: 'input' },
|
|
42
|
+
);
|
|
43
|
+
|
|
30
44
|
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
31
45
|
z.strictObject({
|
|
46
|
+
daemon: DAEMON_FIELD,
|
|
32
47
|
cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
|
|
33
48
|
name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
|
|
34
49
|
prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
|
|
@@ -117,7 +132,11 @@ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
117
132
|
|
|
118
133
|
const REPORT_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
119
134
|
z.strictObject({
|
|
120
|
-
report: z
|
|
135
|
+
report: z
|
|
136
|
+
.string()
|
|
137
|
+
.describe(
|
|
138
|
+
"The report handle of the report's event from atc_events_read, or the event's cursor when it carries no report handle",
|
|
139
|
+
),
|
|
121
140
|
}),
|
|
122
141
|
{ io: 'input' },
|
|
123
142
|
);
|
|
@@ -316,10 +335,13 @@ interface MCPToolDefinition {
|
|
|
316
335
|
// What the connected daemon has to announce for the tool to be listed at
|
|
317
336
|
// all, for its output schema to be declared, and for each listed input
|
|
318
337
|
// property to be offered. An older daemon gets the tool without them.
|
|
338
|
+
// `outputUnless` leaves the output schema out when the caller announces
|
|
339
|
+
// that feature, for a tool whose result takes another shape there.
|
|
319
340
|
readonly requires?: {
|
|
320
|
-
readonly tool?:
|
|
321
|
-
readonly output?:
|
|
322
|
-
readonly
|
|
341
|
+
readonly tool?: FleetFeature;
|
|
342
|
+
readonly output?: FleetFeature;
|
|
343
|
+
readonly outputUnless?: FleetFeature;
|
|
344
|
+
readonly inputs?: Readonly<Record<string, FleetFeature>>;
|
|
323
345
|
};
|
|
324
346
|
readonly scope: GrantScope;
|
|
325
347
|
}
|
|
@@ -373,6 +395,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
373
395
|
inputSchema: SPAWN_INPUT,
|
|
374
396
|
requires: {
|
|
375
397
|
inputs: {
|
|
398
|
+
daemon: 'fleet.daemons',
|
|
376
399
|
model: 'spawn.options',
|
|
377
400
|
effort: 'spawn.options',
|
|
378
401
|
idempotencyKey: 'spawn.idempotency',
|
|
@@ -452,7 +475,17 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
452
475
|
annotations: READ_ONLY,
|
|
453
476
|
scope: 'read',
|
|
454
477
|
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
478
|
+
inputSchema: DIRS_INPUT,
|
|
479
|
+
requires: { inputs: { daemon: 'fleet.daemons' } },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: 'atc_daemons_list',
|
|
483
|
+
annotations: READ_ONLY,
|
|
484
|
+
scope: 'read',
|
|
485
|
+
description:
|
|
486
|
+
"List the atc daemons this server routes to: each one's name, state (up, down, unauthorized, changed, or outdated), build, daemonID, and features, plus defaultDaemon, the daemon a spawn or directory listing without daemon goes to. Session and message ids start with the name of the daemon that holds them.",
|
|
455
487
|
inputSchema: NO_INPUT,
|
|
488
|
+
requires: { tool: 'fleet.daemons' },
|
|
456
489
|
},
|
|
457
490
|
{
|
|
458
491
|
name: 'atc_agents_list',
|
|
@@ -462,7 +495,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
462
495
|
"List the agents this atc host can run sessions under, plus the host itself (daemon: hostname, platform, arch, build). Each agent has its id (pass it as atc_session_spawn's agent), label, kind (the agent CLI family it runs), installed (whether its binary resolves on this host; a registered agent that is not installed cannot spawn), capabilities (spawn, readTranscript, message, attach, screen, input), models (the model names the config sets for it, or null), and spawnOptions when the daemon supports spawn options. spawnOptions holds model and effort, each with supported (whether atc passes it to the agent CLI), available (whether a spawn on this host can pass it now), values (the accepted set, or null for any alias or model name), examples (each with the provider model it resolves to, when the config maps one), default (the configured value, or null for the CLI's own), backendEffect (applied, or unverified when the backend may ignore it), and a note. atc_session_spawn accepts exactly the available options. When the daemon supports targets, it also returns targets (each with its id, provider kind, identity, available, default, and capabilities), spawnDefaults (the agent and target a spawn without either runs with; a null target means such a spawn is refused), configRevision (a digest that changes whenever the target config does), and targetErrors (config problems that leave a target, or every target, unusable; a config file that exists but cannot be read or parsed is scope config, problem config_malformed or config_unreadable, with its path and detail, and refuses every spawn, local included). It never includes credentials, environment values, or endpoints, and holds nothing about which plans or subscriptions an agent's account has.",
|
|
463
496
|
inputSchema: NO_INPUT,
|
|
464
497
|
outputSchema: AGENTS_OUTPUT,
|
|
465
|
-
requires: { tool: 'agents.list', output: 'spawn.options' },
|
|
498
|
+
requires: { tool: 'agents.list', output: 'spawn.options', outputUnless: 'fleet.daemons' },
|
|
466
499
|
},
|
|
467
500
|
{
|
|
468
501
|
name: 'atc_session_get',
|
|
@@ -485,7 +518,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
485
518
|
annotations: READ_ONLY,
|
|
486
519
|
scope: 'read',
|
|
487
520
|
description:
|
|
488
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the
|
|
521
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the report handle of that event when it carries one, else its cursor. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
489
522
|
inputSchema: EVENTS_READ_INPUT,
|
|
490
523
|
outputSchema: EVENTS_OUTPUT,
|
|
491
524
|
requires: { output: 'events.more', inputs: { session: 'events.session' } },
|
|
@@ -495,7 +528,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
495
528
|
annotations: READ_ONLY,
|
|
496
529
|
scope: 'read',
|
|
497
530
|
description:
|
|
498
|
-
"Read one report's full text without messaging the session that sent it. Pass the
|
|
531
|
+
"Read one report's full text without messaging the session that sent it. Pass the report handle of the report's event from atc_events_read, or the event's cursor when it carries none. Returns the report cursor, at, the session id and name, the label, the text (up to 64 KiB, as the session sent it), and complete, which is false for a report recorded before atc kept full texts: its text is then only the preview the event held. A cursor of an event that is not a report answers as an unknown report.",
|
|
499
532
|
inputSchema: REPORT_GET_INPUT,
|
|
500
533
|
outputSchema: REPORT_OUTPUT,
|
|
501
534
|
requires: { tool: 'report.get' },
|
|
@@ -21,6 +21,8 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
21
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
22
22
|
sources: 'sources.list and sources.interpret',
|
|
23
23
|
'git.probe': 'git.probe',
|
|
24
|
+
'transport.tcp': 'a TCP connection to the daemon',
|
|
25
|
+
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -25,6 +25,12 @@ export function runTool(
|
|
|
25
25
|
.with('atc_session_list', async () => {
|
|
26
26
|
const ok = await caller.sendRequest('session.list');
|
|
27
27
|
|
|
28
|
+
// A caller that routes across named daemons adds each daemon's state,
|
|
29
|
+
// so a daemon that is down never reads as one with no sessions.
|
|
30
|
+
if (ok['daemons'] !== undefined) {
|
|
31
|
+
return buildObjectResult({ sessions: ok['sessions'], daemons: ok['daemons'] });
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
// The text stays the bare list older clients read; structured content
|
|
29
35
|
// has to be an object.
|
|
30
36
|
return {
|
|
@@ -38,6 +44,7 @@ export function runTool(
|
|
|
38
44
|
const key = parseIdempotencyKey(args['idempotencyKey']);
|
|
39
45
|
|
|
40
46
|
const params = {
|
|
47
|
+
...(typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {}),
|
|
41
48
|
cwd: args['cwd'],
|
|
42
49
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
43
50
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
@@ -132,10 +139,17 @@ export function runTool(
|
|
|
132
139
|
};
|
|
133
140
|
})
|
|
134
141
|
.with('atc_dirs_list', async () => {
|
|
135
|
-
const
|
|
142
|
+
const params = typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {};
|
|
143
|
+
|
|
144
|
+
const ok = await caller.sendRequest('dirs.list', params);
|
|
136
145
|
|
|
137
146
|
return { text: JSON.stringify(ok['dirs'], null, 2), structured: { dirs: ok['dirs'] } };
|
|
138
147
|
})
|
|
148
|
+
.with('atc_daemons_list', async () => {
|
|
149
|
+
const ok = await caller.sendRequest('daemons.list');
|
|
150
|
+
|
|
151
|
+
return buildObjectResult(ok);
|
|
152
|
+
})
|
|
139
153
|
.with('atc_agents_list', async () => {
|
|
140
154
|
const ok = await caller.sendRequest('agents.list', {}, ['agents.list']);
|
|
141
155
|
|
|
@@ -35,6 +35,9 @@ interface MCPHTTPServerOptions {
|
|
|
35
35
|
|
|
36
36
|
// How long a rotated refresh token still answers with its successor.
|
|
37
37
|
readonly refreshReuseSeconds?: number;
|
|
38
|
+
|
|
39
|
+
// Serves `/healthz` and `/readyz` for an orchestrator's probes.
|
|
40
|
+
readonly probes?: boolean;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -60,7 +63,11 @@ export interface MCPHTTPServer {
|
|
|
60
63
|
* method, path, JSON-RPC method and tool, status, duration, and MCP protocol
|
|
61
64
|
* version, and never a body, query, credential, or address. A request whose Host header is not the server's own is
|
|
62
65
|
* refused, so a DNS rebinding page cannot reach it through a browser, and a
|
|
63
|
-
* browser form post from any other origin is refused too.
|
|
66
|
+
* browser form post from any other origin is refused too. With `probes`,
|
|
67
|
+
* `/healthz` answers 200 while the server serves and `/readyz` answers 200
|
|
68
|
+
* once the authorization server's database is open, else 503; both pass
|
|
69
|
+
* the Host check first and answer with an empty body, so they disclose
|
|
70
|
+
* nothing about the fleet.
|
|
64
71
|
*/
|
|
65
72
|
export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise<MCPHTTPServer> {
|
|
66
73
|
// Normalized before binding, so an invalid public URL throws with no port
|
|
@@ -79,7 +86,10 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
79
86
|
);
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
const holder: { ready: ServerState | null } = {
|
|
89
|
+
const holder: { ready: ServerState | null; hosts: ReadonlySet<string> } = {
|
|
90
|
+
ready: null,
|
|
91
|
+
hosts: new Set(),
|
|
92
|
+
};
|
|
83
93
|
|
|
84
94
|
const server = Bun.serve({
|
|
85
95
|
hostname: options.host,
|
|
@@ -94,7 +104,11 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
94
104
|
let rpc: RPCLabel | null = null;
|
|
95
105
|
let response: Response;
|
|
96
106
|
|
|
97
|
-
|
|
107
|
+
const path = new URL(request.url).pathname;
|
|
108
|
+
|
|
109
|
+
if (options.probes === true && PROBE_PATHS.has(path)) {
|
|
110
|
+
response = answerProbeRequest(request, path, holder.hosts, state !== null);
|
|
111
|
+
} else if (state === null) {
|
|
98
112
|
response = new Response(null, { status: 503 });
|
|
99
113
|
} else {
|
|
100
114
|
try {
|
|
@@ -122,6 +136,16 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
122
136
|
const port = server.port ?? options.port;
|
|
123
137
|
const local = `http://127.0.0.1:${port}`;
|
|
124
138
|
const origin = publicOrigin ?? local;
|
|
139
|
+
|
|
140
|
+
const hosts = new Set([
|
|
141
|
+
new URL(origin).host,
|
|
142
|
+
`127.0.0.1:${port}`,
|
|
143
|
+
`localhost:${port}`,
|
|
144
|
+
...options.allowedHosts,
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
holder.hosts = hosts;
|
|
148
|
+
|
|
125
149
|
let store: HTTPServerContext['store'];
|
|
126
150
|
|
|
127
151
|
try {
|
|
@@ -139,12 +163,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
holder.ready = {
|
|
142
|
-
hosts
|
|
143
|
-
new URL(origin).host,
|
|
144
|
-
`127.0.0.1:${port}`,
|
|
145
|
-
`localhost:${port}`,
|
|
146
|
-
...options.allowedHosts,
|
|
147
|
-
]),
|
|
166
|
+
hosts,
|
|
148
167
|
origins: new Set([origin, local, `http://localhost:${port}`]),
|
|
149
168
|
ctx: {
|
|
150
169
|
caller: options.caller,
|
|
@@ -168,6 +187,31 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
|
|
|
168
187
|
};
|
|
169
188
|
}
|
|
170
189
|
|
|
190
|
+
const PROBE_PATHS: ReadonlySet<string> = new Set(['/healthz', '/readyz']);
|
|
191
|
+
|
|
192
|
+
// A probe from a Host other than the server's own is refused like any other
|
|
193
|
+
// request; `ready` is whether the authorization server's database is open.
|
|
194
|
+
function answerProbeRequest(
|
|
195
|
+
request: Request,
|
|
196
|
+
path: string,
|
|
197
|
+
hosts: ReadonlySet<string>,
|
|
198
|
+
ready: boolean,
|
|
199
|
+
): Response {
|
|
200
|
+
const host = request.headers.get('host');
|
|
201
|
+
|
|
202
|
+
if (host === null || !hosts.has(host)) {
|
|
203
|
+
return new Response(null, { status: 403 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
207
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const isReady = path === '/healthz' || ready;
|
|
211
|
+
|
|
212
|
+
return new Response(null, { status: isReady ? 200 : 503 });
|
|
213
|
+
}
|
|
214
|
+
|
|
171
215
|
// An IPv6 address takes the brackets a URL puts around it.
|
|
172
216
|
function formatBindURL(host: string, port: number): string {
|
|
173
217
|
const bracketed = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
package/src/mcp/types.ts
CHANGED
|
@@ -2,6 +2,13 @@ import type { DaemonFeature } from '../protocol/daemon-features';
|
|
|
2
2
|
import type { ApprovalState } from './approval-state';
|
|
3
3
|
import type { openMCPAuth } from './open-mcp-auth';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A feature the tool list can depend on: one a daemon announces, or
|
|
7
|
+
* `fleet.daemons`, which only a caller that routes across named daemons
|
|
8
|
+
* announces, for the tools and inputs that pick a daemon.
|
|
9
|
+
*/
|
|
10
|
+
export type FleetFeature = DaemonFeature | 'fleet.daemons';
|
|
11
|
+
|
|
5
12
|
// The slice of the daemon client the tool handlers need: requests, and the
|
|
6
13
|
// features the connected daemon announced at its handshake. A request that
|
|
7
14
|
// lists required features is checked against the connection it is about to
|
|
@@ -14,7 +21,7 @@ export interface FleetCaller {
|
|
|
14
21
|
required?: readonly DaemonFeature[],
|
|
15
22
|
principal?: string,
|
|
16
23
|
) => Promise<Readonly<Record<string, unknown>>>;
|
|
17
|
-
readonly readFeatures: () => Promise<ReadonlySet<
|
|
24
|
+
readonly readFeatures: () => Promise<ReadonlySet<FleetFeature>>;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export interface ToolContext {
|
|
@@ -64,6 +64,15 @@ export const DAEMON_FEATURES = [
|
|
|
64
64
|
// `git.probe` exists, and `session.spawn` takes a git workspace with both
|
|
65
65
|
// `ref` and `sha`.
|
|
66
66
|
'git.probe',
|
|
67
|
+
|
|
68
|
+
// The daemon can serve the client protocol on a TCP listener whose
|
|
69
|
+
// handshake takes a bearer token.
|
|
70
|
+
'transport.tcp',
|
|
71
|
+
|
|
72
|
+
// A keyed `session.spawn` or `session.message` takes `replayOnly`, which
|
|
73
|
+
// replays a key the daemon holds and refuses one it does not hold with
|
|
74
|
+
// `idempotency_key_unknown`, running nothing.
|
|
75
|
+
'idempotency.replayOnly',
|
|
67
76
|
] as const;
|
|
68
77
|
|
|
69
78
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -23,6 +23,10 @@ const IDEMPOTENCY_KEY = z
|
|
|
23
23
|
.max(200, 'idempotencyKey must be at most 200 characters')
|
|
24
24
|
.optional();
|
|
25
25
|
|
|
26
|
+
// With a key, only replays what the key already holds: a key the daemon
|
|
27
|
+
// does not hold is refused and nothing runs.
|
|
28
|
+
const REPLAY_ONLY = z.boolean({ error: 'replayOnly must be a boolean' }).optional();
|
|
29
|
+
|
|
26
30
|
// A full commit id, SHA-1 or SHA-256.
|
|
27
31
|
const COMMIT_ID = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
28
32
|
|
|
@@ -168,6 +172,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
168
172
|
// A retry carrying the same key replays the first spawn's answer instead
|
|
169
173
|
// of spawning again.
|
|
170
174
|
idempotencyKey: IDEMPOTENCY_KEY,
|
|
175
|
+
replayOnly: REPLAY_ONLY,
|
|
171
176
|
|
|
172
177
|
// The execution target the session runs on; absent runs on the default
|
|
173
178
|
// target, and a target the daemon cannot use refuses the spawn.
|
|
@@ -260,6 +265,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
260
265
|
// A retry carrying the same key replays the first send's message
|
|
261
266
|
// instead of sending another.
|
|
262
267
|
idempotencyKey: IDEMPOTENCY_KEY,
|
|
268
|
+
replayOnly: REPLAY_ONLY,
|
|
263
269
|
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
264
270
|
'session.tap': SESSION_DEFAULTED,
|
|
265
271
|
'message.get': z
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DaemonClient } from './client/daemon-client';
|
|
2
|
+
import { daemonRecordFile, daemonSocketPath } from './shared/config';
|
|
3
|
+
import { findDaemonRecord } from './shared/find-daemon-record';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prints the running daemon's `daemonID`, the identity a gateway registry
|
|
7
|
+
* pins, read from the handshake over the owner's local socket: the one this
|
|
8
|
+
* environment computes, else the one the daemon recorded in the state
|
|
9
|
+
* directory. When no daemon answers, prints a hint to stderr and exits
|
|
10
|
+
* nonzero instead of booting one.
|
|
11
|
+
*/
|
|
12
|
+
export async function runDaemonID(build: string): Promise<void> {
|
|
13
|
+
const record = findDaemonRecord(daemonRecordFile);
|
|
14
|
+
const paths = [daemonSocketPath, ...(record === null ? [] : [record.socketPath])];
|
|
15
|
+
|
|
16
|
+
for (const path of new Set(paths)) {
|
|
17
|
+
const daemonID = await tryReadDaemonID(path, build);
|
|
18
|
+
|
|
19
|
+
if (daemonID !== null) {
|
|
20
|
+
console.log(daemonID);
|
|
21
|
+
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
console.error(`atc daemon id: no daemon at ${daemonSocketPath} — start one first`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The daemonID the daemon at the socket returns, or null when nothing
|
|
31
|
+
// answers there or the answer holds none.
|
|
32
|
+
async function tryReadDaemonID(socketPath: string, build: string): Promise<string | null> {
|
|
33
|
+
let client: DaemonClient;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
client = await DaemonClient.open(socketPath);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const hello = await client.sendHello(build);
|
|
43
|
+
|
|
44
|
+
const daemonID = hello['daemonID'];
|
|
45
|
+
|
|
46
|
+
return typeof daemonID === 'string' ? daemonID : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
} finally {
|
|
50
|
+
client.stop();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -6,6 +6,9 @@ export interface DaemonRecord {
|
|
|
6
6
|
readonly socketPath: string;
|
|
7
7
|
readonly reporterSocketPath: string;
|
|
8
8
|
readonly eventsSocketPath: string | null;
|
|
9
|
+
|
|
10
|
+
// The port the TCP listener bound, or null without one.
|
|
11
|
+
readonly listenPort: number | null;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
const RECORD_SCHEMA = z.object({
|
|
@@ -13,12 +16,14 @@ const RECORD_SCHEMA = z.object({
|
|
|
13
16
|
socketPath: z.string().min(1),
|
|
14
17
|
reporterSocketPath: z.string().min(1),
|
|
15
18
|
eventsSocketPath: z.string().min(1).nullable(),
|
|
19
|
+
listenPort: z.number().int().min(1).max(65_535).nullable().default(null),
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
* Reads the record a running daemon keeps in its state directory: its pid
|
|
20
|
-
*
|
|
21
|
-
* computes other socket paths uses to find it
|
|
23
|
+
* Reads the record a running daemon keeps in its state directory: its pid,
|
|
24
|
+
* the socket paths it listens on, which a client whose environment
|
|
25
|
+
* computes other socket paths uses to find it, and the port its TCP
|
|
26
|
+
* listener bound. A missing, torn, or
|
|
22
27
|
* malformed record reads as null; a record left by a crashed daemon still
|
|
23
28
|
* parses, so a caller proves liveness by connecting.
|
|
24
29
|
*/
|
package/src/store/state-store.ts
CHANGED
|
@@ -785,6 +785,22 @@ export class StateStore {
|
|
|
785
785
|
return toIdempotencyRecord(row);
|
|
786
786
|
}
|
|
787
787
|
|
|
788
|
+
// The record a key holds under its principal and operation, or null when
|
|
789
|
+
// the ledger holds none.
|
|
790
|
+
async findIdempotencyKey(
|
|
791
|
+
id: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
792
|
+
): Promise<IdempotencyRecord | null> {
|
|
793
|
+
const row = await this.db
|
|
794
|
+
.selectFrom('idempotency')
|
|
795
|
+
.selectAll()
|
|
796
|
+
.where('principal', '=', id.principal)
|
|
797
|
+
.where('operation', '=', id.operation)
|
|
798
|
+
.where('key', '=', id.key)
|
|
799
|
+
.executeTakeFirst();
|
|
800
|
+
|
|
801
|
+
return row === undefined ? null : toIdempotencyRecord(row);
|
|
802
|
+
}
|
|
803
|
+
|
|
788
804
|
async updateIdempotencyCompleted(
|
|
789
805
|
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
790
806
|
result: string,
|