@zgeoff/atc 2.10.4 → 2.12.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 (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -0,0 +1,19 @@
1
+ import type { DaemonFeature } from './daemon-features';
2
+ import { DAEMON_FEATURES } from './daemon-features';
3
+
4
+ /**
5
+ * Reads the features a `daemon.hello` answer announces. A missing or
6
+ * malformed list, which an older daemon sends, is no features, and a name
7
+ * this build does not know is dropped.
8
+ */
9
+ export function parseDaemonFeatures(
10
+ hello: Readonly<Record<string, unknown>>,
11
+ ): ReadonlySet<DaemonFeature> {
12
+ const announced = hello['features'];
13
+
14
+ if (!Array.isArray(announced)) {
15
+ return new Set();
16
+ }
17
+
18
+ return new Set(DAEMON_FEATURES.filter((feature) => announced.includes(feature)));
19
+ }
@@ -5,7 +5,7 @@ import { isRecord } from '../shared/report';
5
5
  * per line. Three message kinds, distinguished by which fields are present —
6
6
  * request (id + m), response (id + ok or err), event (ev).
7
7
  */
8
- export const PROTOCOL_V = 3;
8
+ export const PROTOCOL_V = 4;
9
9
 
10
10
  // Control lines are capped before buffering; PTY output is split into chunks
11
11
  // so a queued response is delayed by at most one chunk.
@@ -20,8 +20,17 @@ const ERROR_CODES = [
20
20
  'no_such_session',
21
21
  'session_dead',
22
22
  'unsupported',
23
+ 'unsupported_operation',
24
+ 'unknown_target',
25
+ 'target_unavailable',
26
+ 'target_changed',
27
+ 'target_config_invalid',
28
+ 'target_forbidden',
23
29
  'already_answered',
24
30
  'too_slow',
31
+ 'stale_epoch',
32
+ 'idempotency_conflict',
33
+ 'outcome_unknown',
25
34
  'internal',
26
35
  ] as const;
27
36
 
@@ -30,6 +39,9 @@ export type ErrorCode = (typeof ERROR_CODES)[number];
30
39
  interface ProtocolError {
31
40
  readonly code: ErrorCode;
32
41
  readonly msg: string;
42
+
43
+ // Structured detail an error code defines for itself.
44
+ readonly data?: Readonly<Record<string, unknown>>;
33
45
  }
34
46
 
35
47
  export interface RequestMsg {
@@ -37,6 +49,10 @@ export interface RequestMsg {
37
49
  readonly id: number;
38
50
  readonly m: string;
39
51
  readonly p?: Readonly<Record<string, unknown>>;
52
+
53
+ // The principal the request acts as, which narrows what the connection
54
+ // may reach for this request alone.
55
+ readonly as?: string;
40
56
  }
41
57
 
42
58
  export interface ResponseMsg {
@@ -61,7 +77,9 @@ export type DecodedMsg =
61
77
  /**
62
78
  * Classifies one NDJSON line. Unknown fields pass through untouched so
63
79
  * additive evolution never breaks a peer; a line that parses but fits no
64
- * message kind is malformed, and the caller closes the connection.
80
+ * message kind is malformed, and the caller closes the connection. An error
81
+ * code this build does not know decodes as `internal` with its message
82
+ * kept, so a peer that adds a code never breaks one that predates it.
65
83
  */
66
84
  export function decodeMessage(line: string): DecodedMsg {
67
85
  let parsed: unknown;
@@ -86,6 +104,13 @@ export function decodeMessage(line: string): DecodedMsg {
86
104
 
87
105
  if (typeof parsed['m'] === 'string') {
88
106
  const p = parsed['p'];
107
+ const as = parsed['as'];
108
+
109
+ // A principal that is not a string would otherwise drop out and leave the
110
+ // request with the connection's whole reach.
111
+ if (as !== undefined && (typeof as !== 'string' || as === '')) {
112
+ return { kind: 'malformed', reason: 'as must be a non-empty string' };
113
+ }
89
114
 
90
115
  return {
91
116
  kind: 'request',
@@ -94,21 +119,22 @@ export function decodeMessage(line: string): DecodedMsg {
94
119
  id: parsed['id'],
95
120
  m: parsed['m'],
96
121
  ...(isRecord(p) ? { p } : {}),
122
+ ...(as === undefined ? {} : { as }),
97
123
  },
98
124
  };
99
125
  }
100
126
 
101
127
  const ok = parsed['ok'];
102
- const err = parsed['err'];
128
+ const err = parseProtocolError(parsed['err']);
103
129
 
104
- if (isRecord(ok) || isProtocolError(err)) {
130
+ if (isRecord(ok) || err !== null) {
105
131
  return {
106
132
  kind: 'response',
107
133
  msg: {
108
134
  v: parsed['v'],
109
135
  id: parsed['id'],
110
136
  ...(isRecord(ok) ? { ok } : {}),
111
- ...(isProtocolError(err) ? { err } : {}),
137
+ ...(err === null ? {} : { err }),
112
138
  },
113
139
  };
114
140
  }
@@ -120,10 +146,17 @@ export function encodeMessage(msg: EventMsg | RequestMsg | ResponseMsg): string
120
146
  return `${JSON.stringify(msg)}\n`;
121
147
  }
122
148
 
123
- function isProtocolError(value: unknown): value is ProtocolError {
124
- return (
125
- isRecord(value) &&
126
- typeof value['msg'] === 'string' &&
127
- ERROR_CODES.some((code) => code === value['code'])
128
- );
149
+ function parseProtocolError(value: unknown): ProtocolError | null {
150
+ if (!isRecord(value) || typeof value['msg'] !== 'string' || typeof value['code'] !== 'string') {
151
+ return null;
152
+ }
153
+
154
+ const raw = value['code'];
155
+ const data = value['data'];
156
+
157
+ return {
158
+ code: ERROR_CODES.find((code) => code === raw) ?? 'internal',
159
+ msg: value['msg'],
160
+ ...(isRecord(data) ? { data } : {}),
161
+ };
129
162
  }
@@ -16,25 +16,40 @@ const SESSION_DEFAULTED = z.object({
16
16
  session: buildDefaultedString('').transform(toSessionID),
17
17
  });
18
18
 
19
+ // The idempotency key a spawn or message may carry, at most 200 characters.
20
+ const IDEMPOTENCY_KEY = z
21
+ .string({ error: 'idempotencyKey must be a string' })
22
+ .min(1, 'idempotencyKey must not be empty')
23
+ .max(200, 'idempotencyKey must be at most 200 characters')
24
+ .optional();
25
+
26
+ // The refusal of a terminal size outside the range a terminal takes.
27
+ const TERMINAL_SIZE_ERROR = 'cols and rows must be whole numbers from 1 to 4096';
28
+
19
29
  export const REQUEST_PARAM_SCHEMAS = {
20
30
  'daemon.hello': z.object({
21
31
  client: buildDefaultedString('unknown client'),
32
+ principal: z
33
+ .string({ error: 'daemon.hello principal must be a non-empty string' })
34
+ .min(1, 'daemon.hello principal must be a non-empty string')
35
+ .optional(),
22
36
  }),
23
37
  'daemon.ping': z.object({}),
24
38
  'daemon.quit': z.object({}),
25
39
  'session.list': z.object({}),
26
40
  'dirs.list': z.object({}),
41
+ 'agents.list': z.object({}),
27
42
  'fleet.list': z.object({}),
28
43
  'fleet.restore': z.object({
29
- cols: buildDefaultedNumber(80),
30
- rows: buildDefaultedNumber(24),
44
+ cols: buildTerminalSize(80),
45
+ rows: buildTerminalSize(24),
31
46
  }),
32
47
  'session.spawn': z.object({
33
48
  cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
34
49
  name: buildDefaultedString(''),
35
50
  prompt: buildDefaultedString(''),
36
- cols: buildDefaultedNumber(80),
37
- rows: buildDefaultedNumber(24),
51
+ cols: buildTerminalSize(80),
52
+ rows: buildTerminalSize(24),
38
53
 
39
54
  resume: buildDefaultedBooleanOrString(false).transform((v) =>
40
55
  typeof v === 'string' ? toAgentSessionID(v) : v,
@@ -44,6 +59,22 @@ export const REQUEST_PARAM_SCHEMAS = {
44
59
  .min(1, 'session.spawn agent must be a non-empty agent id')
45
60
  .optional(),
46
61
 
62
+ // Per-session overrides; the daemon checks each against what the agent
63
+ // advertises before anything spawns.
64
+ model: z.string({ error: 'session.spawn model must be a string' }).optional(),
65
+ effort: z.string({ error: 'session.spawn effort must be a string' }).optional(),
66
+
67
+ // A retry carrying the same key replays the first spawn's answer instead
68
+ // of spawning again.
69
+ idempotencyKey: IDEMPOTENCY_KEY,
70
+
71
+ // The execution target the session runs on; absent runs on the default
72
+ // target, and a target the daemon cannot use refuses the spawn.
73
+ target: z
74
+ .string({ error: 'session.spawn target must be a non-empty target id' })
75
+ .min(1, 'session.spawn target must be a non-empty target id')
76
+ .optional(),
77
+
47
78
  // The session the new one is a sub-session of; absent or empty spawns a
48
79
  // top-level session.
49
80
  parent: z.preprocess(
@@ -58,16 +89,16 @@ export const REQUEST_PARAM_SCHEMAS = {
58
89
  pinned: buildOptionalBoolean(),
59
90
  }),
60
91
  'session.attach': SESSION_DEFAULTED.extend({
61
- cols: buildDefaultedNumber(80),
62
- rows: buildDefaultedNumber(24),
92
+ cols: buildTerminalSize(80),
93
+ rows: buildTerminalSize(24),
63
94
  }),
64
95
  'session.detach': SESSION_DEFAULTED,
65
96
  'session.input': SESSION_DEFAULTED.extend({
66
97
  d: buildDefaultedString(''),
67
98
  }),
68
99
  'session.resize': SESSION_DEFAULTED.extend({
69
- cols: buildDefaultedNumber(0),
70
- rows: buildDefaultedNumber(0),
100
+ cols: buildTerminalSize(0),
101
+ rows: buildTerminalSize(0),
71
102
  }).refine((v) => v.cols >= 1 && v.rows >= 1, {
72
103
  message: 'session.resize requires positive cols and rows',
73
104
  }),
@@ -77,8 +108,8 @@ export const REQUEST_PARAM_SCHEMAS = {
77
108
  prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
78
109
  }),
79
110
  'session.adopt': SESSION_DEFAULTED.extend({
80
- cols: buildDefaultedNumber(80),
81
- rows: buildDefaultedNumber(24),
111
+ cols: buildTerminalSize(80),
112
+ rows: buildTerminalSize(24),
82
113
  }),
83
114
  'permission.respond': z
84
115
  .object({
@@ -96,15 +127,28 @@ export const REQUEST_PARAM_SCHEMAS = {
96
127
  'events.read': z.object({
97
128
  cursor: buildOptionalCursor(),
98
129
  limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
99
- waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
130
+ waitMs: buildDefaultedWait(),
131
+
132
+ // Limits the read to one session's events; absent or empty reads the whole fleet.
133
+ session: z.preprocess(
134
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
135
+ z.string().transform(toSessionID).optional(),
136
+ ),
100
137
  }),
101
138
  'session.message': SESSION_DEFAULTED.extend({
102
139
  from: buildDefaultedNonEmptyString('unknown'),
103
140
  text: buildDefaultedString(''),
141
+
142
+ // A retry carrying the same key replays the first send's message
143
+ // instead of sending another.
144
+ idempotencyKey: IDEMPOTENCY_KEY,
104
145
  }).refine((v) => v.text !== '', { message: 'session.message requires text' }),
105
146
  'session.tap': SESSION_DEFAULTED,
106
147
  'message.get': z
107
- .object({ message: buildDefaultedString('').transform(toMessageID) })
148
+ .object({
149
+ message: buildDefaultedString('').transform(toMessageID),
150
+ waitMs: buildDefaultedWait(),
151
+ })
108
152
  .refine((v) => v.message !== '', { message: 'message.get requires a message' }),
109
153
  'message.ack': SESSION_DEFAULTED.extend({
110
154
  message: buildDefaultedString('').transform(toMessageID),
@@ -115,10 +159,32 @@ function buildDefaultedString(fallback: string) {
115
159
  return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
116
160
  }
117
161
 
162
+ // A terminal dimension: absent or not a number takes the fallback, and a
163
+ // number must be a whole number in range, since the terminal emulator and
164
+ // the PTY both assume one and a fractional size throws only after the
165
+ // process starts.
166
+ function buildTerminalSize(fallback: number) {
167
+ return z.preprocess(
168
+ (v) => (typeof v === 'number' ? v : undefined),
169
+ z
170
+ .number({ error: TERMINAL_SIZE_ERROR })
171
+ .int(TERMINAL_SIZE_ERROR)
172
+ .min(1, TERMINAL_SIZE_ERROR)
173
+ .max(4096, TERMINAL_SIZE_ERROR)
174
+ .default(fallback),
175
+ );
176
+ }
177
+
118
178
  function buildDefaultedNumber(fallback: number) {
119
179
  return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
120
180
  }
121
181
 
182
+ // How long a read may hold its request open, in milliseconds: 0 when absent,
183
+ // clamped to 0–30000.
184
+ function buildDefaultedWait() {
185
+ return buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000));
186
+ }
187
+
122
188
  function buildDefaultedBooleanOrString(fallback: boolean | string) {
123
189
  return z.preprocess(
124
190
  (v) => (typeof v === 'boolean' || typeof v === 'string' ? v : undefined),
package/src/report.ts CHANGED
@@ -3,13 +3,21 @@ import { REPORT_KINDS } from './shared/report-kinds';
3
3
 
4
4
  interface ReportOptions {
5
5
  readonly message: string;
6
+
7
+ // Comma-separated ids of every message one turn answered, reported together.
8
+ readonly messages: string;
6
9
  readonly label: string;
10
+
11
+ // The turn whose final reply an `answered` report carries; empty when unknown.
12
+ readonly turn: string;
7
13
  }
8
14
 
9
15
  /**
10
16
  * Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
11
17
  * atc socket as a Report envelope of the given kind. An `answered` report
12
- * carries stdin as the final text for the given message; a `note` carries
18
+ * carries stdin as the final reply of the turn that carried the given
19
+ * message, or every given message at once, plus that turn's id when one is
20
+ * given; a `note` carries
13
21
  * it as text for the user under the given label, `progress` when none is
14
22
  * given. Always exits 0 so it never blocks the session it reports on.
15
23
  */
@@ -44,9 +52,18 @@ function buildReportPayload(
44
52
  kind: string,
45
53
  options: ReportOptions,
46
54
  stdin: string,
47
- ): Record<string, string> | null {
55
+ ): Record<string, string | readonly string[]> | null {
48
56
  if (kind === 'answered') {
49
- return options.message === '' ? null : { kind, message: options.message, answer: stdin };
57
+ const messages = options.messages.split(',').filter((id) => id !== '');
58
+ const turn = options.turn === '' ? {} : { turn: options.turn };
59
+
60
+ if (messages.length > 0) {
61
+ return { kind, messages, answer: stdin, ...turn };
62
+ }
63
+
64
+ return options.message === ''
65
+ ? null
66
+ : { kind, message: options.message, answer: stdin, ...turn };
50
67
  }
51
68
 
52
69
  if (kind === 'note') {
@@ -3,6 +3,6 @@ import type { Tagged } from 'type-fest';
3
3
  /**
4
4
  * The session id an agent CLI issues for its own resume mechanism — what
5
5
  * `claude --resume <id>` takes. Adapters mint it from a hook payload or a
6
- * stored fleet row, and the fleet table keys on it.
6
+ * stored fleet row, and a fleet row holds at most one.
7
7
  */
8
8
  export type AgentSessionID = Tagged<string, 'AgentSessionID'>;
@@ -0,0 +1,51 @@
1
+ import { isRecord } from './report';
2
+
3
+ interface PrincipalsConfig {
4
+ // The targets each principal may use, by principal; null when the config
5
+ // has no `principals` key.
6
+ readonly principals: ReadonlyMap<string, readonly string[]> | null;
7
+ readonly errors: readonly string[];
8
+ }
9
+
10
+ /**
11
+ * Reads the `principals` map: each principal's id to the names of the
12
+ * targets it may use. No `principals` key is null. Otherwise the config
13
+ * fails closed: a malformed map grants nothing to anyone, and a malformed
14
+ * entry grants nothing to its principal. Each problem is an error.
15
+ */
16
+ export function collectPrincipals(raw: unknown): PrincipalsConfig {
17
+ if (raw === undefined) {
18
+ return { principals: null, errors: [] };
19
+ }
20
+
21
+ if (!isRecord(raw) || Array.isArray(raw)) {
22
+ return {
23
+ principals: new Map(),
24
+ errors: ['principals must be an object of principal ids, so no principal gets a target'],
25
+ };
26
+ }
27
+
28
+ const principals = new Map<string, readonly string[]>();
29
+
30
+ const errors: string[] = [];
31
+
32
+ for (const [id, entry] of Object.entries(raw)) {
33
+ const targets = isRecord(entry) ? entry['targets'] : undefined;
34
+
35
+ if (
36
+ id === '' ||
37
+ !Array.isArray(targets) ||
38
+ !targets.every((target) => typeof target === 'string' && target !== '')
39
+ ) {
40
+ errors.push(
41
+ `principal ${JSON.stringify(id)} must be an object whose targets is an array of target names, so it gets no target`,
42
+ );
43
+
44
+ continue;
45
+ }
46
+
47
+ principals.set(id, targets);
48
+ }
49
+
50
+ return { principals, errors };
51
+ }
@@ -0,0 +1,144 @@
1
+ import { formatJSONKind } from './format-json-kind';
2
+ import { isRecord } from './report';
3
+
4
+ /**
5
+ * A named place sessions run: the execution provider kind that serves it,
6
+ * and the options that kind takes.
7
+ */
8
+ export interface TargetConfig {
9
+ readonly id: string;
10
+ readonly provider: string;
11
+ readonly options: Readonly<Record<string, unknown>>;
12
+ }
13
+
14
+ /**
15
+ * A config problem that leaves a target, or every target, unusable: the
16
+ * config file as a whole, the `targets` map, one entry of it, or
17
+ * `defaultTarget`.
18
+ */
19
+ export type TargetConfigError = TargetSectionError | ConfigFileError;
20
+
21
+ interface TargetSectionError {
22
+ readonly scope: 'targets' | 'target' | 'defaultTarget';
23
+
24
+ // The entry the problem is in; present only for an entry's problem.
25
+ readonly target?: string;
26
+ readonly problem: string;
27
+ }
28
+
29
+ /**
30
+ * A config file that exists but cannot be used, so no target in it can be
31
+ * trusted: `config_malformed` when its text is not JSON or its root is not
32
+ * an object, `config_unreadable` when reading it fails for any reason but
33
+ * its absence. `detail` holds the parse message, the root's kind, or the
34
+ * read's error code.
35
+ */
36
+ interface ConfigFileError {
37
+ readonly scope: 'config';
38
+ readonly problem: 'config_malformed' | 'config_unreadable';
39
+ readonly path: string;
40
+ readonly detail: string;
41
+ }
42
+
43
+ interface TargetsConfig {
44
+ // Every well-formed entry, in config order.
45
+ readonly targets: readonly TargetConfig[];
46
+
47
+ // The target a spawn without a target runs on, or null when the config
48
+ // gives none it can use.
49
+ readonly defaultTarget: string | null;
50
+ readonly errors: readonly TargetConfigError[];
51
+ }
52
+
53
+ // The target a config holds when it sets no targets of its own: the
54
+ // daemon's own machine.
55
+ const LOCAL_TARGET: TargetConfig = { id: 'local', provider: 'local-pty', options: {} };
56
+
57
+ /**
58
+ * Reads the `targets` map and `defaultTarget`. No `targets` key holds the
59
+ * one implicit `local` target as the default. Otherwise the config fails
60
+ * closed: a malformed map holds no targets, a malformed entry is left out,
61
+ * and a `defaultTarget` that is not a string or matches no well-formed
62
+ * entry leaves no default. Each problem is an error, so a spawn that
63
+ * resolves through it fails with that error instead of running on `local`.
64
+ * Without `defaultTarget`, the default is the `local` entry when the map
65
+ * holds a well-formed one, and none otherwise.
66
+ */
67
+ export function collectTargets(rawTargets: unknown, rawDefault: unknown): TargetsConfig {
68
+ if (rawTargets === undefined && rawDefault === undefined) {
69
+ return { targets: [LOCAL_TARGET], defaultTarget: 'local', errors: [] };
70
+ }
71
+
72
+ const collected = collectTargetEntries(rawTargets);
73
+
74
+ const ids = new Set(collected.targets.map((target) => target.id));
75
+
76
+ if (rawDefault === undefined) {
77
+ return {
78
+ targets: collected.targets,
79
+ defaultTarget: ids.has('local') ? 'local' : null,
80
+ errors: collected.errors,
81
+ };
82
+ }
83
+
84
+ if (typeof rawDefault === 'string' && ids.has(rawDefault)) {
85
+ return { targets: collected.targets, defaultTarget: rawDefault, errors: collected.errors };
86
+ }
87
+
88
+ // The problem gives the value's kind, never the value: a wrongly typed
89
+ // defaultTarget can hold anything the file holds.
90
+ const problem =
91
+ typeof rawDefault === 'string'
92
+ ? 'defaultTarget: matches no well-formed target in targets'
93
+ : `defaultTarget: expected a string, got ${formatJSONKind(rawDefault)}`;
94
+
95
+ return {
96
+ targets: collected.targets,
97
+ defaultTarget: null,
98
+ errors: [...collected.errors, { scope: 'defaultTarget', problem }],
99
+ };
100
+ }
101
+
102
+ interface TargetEntries {
103
+ readonly targets: readonly TargetConfig[];
104
+ readonly errors: readonly TargetSectionError[];
105
+ }
106
+
107
+ function collectTargetEntries(raw: unknown): TargetEntries {
108
+ // A defaultTarget without a targets map names one of the implicit targets.
109
+ if (raw === undefined) {
110
+ return { targets: [LOCAL_TARGET], errors: [] };
111
+ }
112
+
113
+ if (!isRecord(raw) || Array.isArray(raw) || Object.keys(raw).length === 0) {
114
+ return {
115
+ targets: [],
116
+ errors: [
117
+ { scope: 'targets', problem: 'targets must be a non-empty object of named targets' },
118
+ ],
119
+ };
120
+ }
121
+
122
+ const targets: TargetConfig[] = [];
123
+ const errors: TargetSectionError[] = [];
124
+
125
+ for (const [id, entry] of Object.entries(raw)) {
126
+ const provider = isRecord(entry) ? entry['provider'] : undefined;
127
+
128
+ if (id === '' || !isRecord(entry) || typeof provider !== 'string' || provider === '') {
129
+ errors.push({
130
+ scope: 'target',
131
+ target: id,
132
+ problem: `target ${JSON.stringify(id)} must be an object with a non-empty string provider`,
133
+ });
134
+
135
+ continue;
136
+ }
137
+
138
+ const { provider: _, ...options } = entry;
139
+
140
+ targets.push({ id, provider, options });
141
+ }
142
+
143
+ return { targets, errors };
144
+ }