@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.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +187 -118
  3. package/src/client/daemon-client.ts +29 -18
  4. package/src/daemon/build-payload-hash.ts +4 -3
  5. package/src/daemon/daemon-connection.ts +270 -20
  6. package/src/daemon/daemon-context.ts +8 -0
  7. package/src/daemon/daemon.ts +186 -32
  8. package/src/daemon/find-token-fingerprint.ts +23 -0
  9. package/src/daemon/handshake-throttle.ts +47 -0
  10. package/src/daemon/idempotency-ledger.ts +20 -2
  11. package/src/daemon/is-allowed-listen-host.ts +63 -0
  12. package/src/daemon/load-listener-tokens.ts +46 -0
  13. package/src/daemon/parse-listen-address.ts +33 -0
  14. package/src/daemon/start-tcp-listener.ts +165 -0
  15. package/src/federation/build-binding-payload-hash.ts +34 -0
  16. package/src/federation/build-daemon-outdated-error.ts +14 -0
  17. package/src/federation/build-events-filter-hash.ts +16 -0
  18. package/src/federation/build-gateway-error.ts +45 -0
  19. package/src/federation/build-gateway-id.ts +14 -0
  20. package/src/federation/build-gateway-result.ts +31 -0
  21. package/src/federation/build-ruled-value.ts +53 -0
  22. package/src/federation/collect-unruled-id-paths.ts +46 -0
  23. package/src/federation/daemon-caller.ts +473 -0
  24. package/src/federation/daemon-pool.ts +56 -0
  25. package/src/federation/decode-gateway-cursor.ts +73 -0
  26. package/src/federation/encode-gateway-cursor.ts +15 -0
  27. package/src/federation/gateway-error.ts +25 -0
  28. package/src/federation/gateway-store.ts +253 -0
  29. package/src/federation/id-rules.ts +85 -0
  30. package/src/federation/load-gateway-registry.ts +29 -0
  31. package/src/federation/max-events-cursor-bytes.ts +4 -0
  32. package/src/federation/max-registry-daemons.ts +26 -0
  33. package/src/federation/merge-event-pages.ts +228 -0
  34. package/src/federation/open-gateway-caller.ts +55 -0
  35. package/src/federation/parse-gateway-id.ts +28 -0
  36. package/src/federation/parse-gateway-registry.ts +123 -0
  37. package/src/federation/pick-daemon-state.ts +46 -0
  38. package/src/federation/plan-event-reads.ts +54 -0
  39. package/src/federation/read-fleet-events.ts +279 -0
  40. package/src/federation/require-serving-daemon.ts +27 -0
  41. package/src/federation/resolve-daemon-request.ts +59 -0
  42. package/src/federation/routing-caller.ts +450 -0
  43. package/src/federation/types.ts +33 -0
  44. package/src/federation/wait-for-outcome.ts +38 -0
  45. package/src/mcp/answer-rpc-request.ts +14 -1
  46. package/src/mcp/build-tool-list.ts +6 -5
  47. package/src/mcp/mcp-tools.ts +41 -8
  48. package/src/mcp/require-daemon-features.ts +2 -0
  49. package/src/mcp/run-tool.ts +15 -1
  50. package/src/mcp/start-mcp-http-server.ts +53 -9
  51. package/src/mcp/types.ts +8 -1
  52. package/src/protocol/daemon-features.ts +9 -0
  53. package/src/protocol/protocol.ts +1 -0
  54. package/src/protocol/request-param-schemas.ts +6 -0
  55. package/src/run-daemon-id.ts +52 -0
  56. package/src/shared/find-daemon-record.ts +8 -3
  57. package/src/store/state-store.ts +16 -0
@@ -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.string().describe("The cursor of the report's event, from atc_events_read"),
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?: DaemonFeature;
321
- readonly output?: DaemonFeature;
322
- readonly inputs?: Readonly<Record<string, DaemonFeature>>;
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 cursor of that event. 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.',
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 cursor of the report's event from atc_events_read. 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.",
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
  /**
@@ -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 ok = await caller.sendRequest('dirs.list');
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 } = { ready: 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
- if (state === null) {
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: new Set([
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<DaemonFeature>>;
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];
@@ -57,6 +57,7 @@ const ERROR_CODES = [
57
57
  'stale_epoch',
58
58
  'idempotency_conflict',
59
59
  'outcome_unknown',
60
+ 'idempotency_key_unknown',
60
61
  'internal',
61
62
  ] as const;
62
63
 
@@ -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
- * and the socket paths it listens on, which a client whose environment
21
- * computes other socket paths uses to find it. A missing, torn, or
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
  */
@@ -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,