@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -0,0 +1,618 @@
1
+ import { isRecord } from '../shared/report';
2
+ import type { HarnessAttachment, HarnessExit, HarnessHandle } from './execution-provider';
3
+ import type {
4
+ ImpPort,
5
+ ImpSessionConnection,
6
+ ImpSessionOutcome,
7
+ ImpSessionRequest,
8
+ ImpSessionStarted,
9
+ PreviousGeneration,
10
+ } from './imp-port';
11
+
12
+ // What the harness asks of the provider that started it.
13
+ interface ImpHarnessHost {
14
+ // Whether the provider is putting the harness's host to sleep, so a
15
+ // connection the sleep ends is the sleep's and not the harness's end.
16
+ readonly isSuspending: () => boolean;
17
+
18
+ // Called once, when the harness ends or the daemon lets go of it.
19
+ readonly onDone: () => void;
20
+
21
+ // Whether impd carries output offsets, so a reconnect can resume.
22
+ readonly offsets: boolean;
23
+
24
+ // The wait before each reconnect after a connection ends without an
25
+ // exit; the harness ends once they run out.
26
+ readonly reconnectDelaysMs: readonly number[];
27
+
28
+ // Settles once the harness may start, such as when its report socket
29
+ // listens; a rejection ends the harness before it starts.
30
+ readonly ready?: Promise<void>;
31
+ }
32
+
33
+ // The bytes a fresh attach sends listeners ahead of its replay: reset the
34
+ // attributes, home the cursor, and clear the screen. A full terminal reset
35
+ // would also undo the modes of every attached client's own terminal.
36
+ const SCREEN_RESET = '\u001B[0m\u001B[H\u001B[2J';
37
+
38
+ /**
39
+ * A harness running in an imp session, as the daemon's one connection to
40
+ * it. Input written before the session starts waits for it. A kill sends
41
+ * the process SIGHUP, as closing a terminal does; a detach closes the
42
+ * connection and leaves the process running under its session name.
43
+ *
44
+ * A connection that ends without an exit reconnects without waking the
45
+ * imp. Where impd carries offsets, a reconnect resumes after the last byte
46
+ * the daemon has and drops any byte below it; a gap, or an imp without
47
+ * offsets, gets a fresh attach that clears the screen before its replay.
48
+ */
49
+ export class ImpHarness implements HarnessHandle {
50
+ private readonly port: ImpPort;
51
+
52
+ private readonly host: ImpHarnessHost;
53
+
54
+ private readonly name: string;
55
+
56
+ private readonly session: string;
57
+
58
+ private readonly dataListeners = new Set<(data: string) => void>();
59
+
60
+ private readonly exitListeners = new Set<(exit: HarnessExit) => void>();
61
+
62
+ private readonly attachmentListeners = new Set<(attachment: HarnessAttachment) => void>();
63
+
64
+ private decoder = new TextDecoder();
65
+
66
+ private connection: ImpSessionConnection | null = null;
67
+
68
+ private started = false;
69
+
70
+ private done = false;
71
+
72
+ private pending: Uint8Array[] = [];
73
+
74
+ private cols: number;
75
+
76
+ private rows: number;
77
+
78
+ // The boot and generation the daemon's output came from, while impd
79
+ // carries offsets for the session.
80
+ private cursor: { readonly bootId: string; readonly generation: string } | null = null;
81
+
82
+ // The offset after the last byte the daemon has, the offset the current
83
+ // connection's next byte sits at, and the mode bytes still owed before
84
+ // it, which have no offset.
85
+ private highWater = 0;
86
+
87
+ private nextOffset = 0;
88
+
89
+ private preludeLeft = 0;
90
+
91
+ // Whether the next connection replaces the screen rather than resuming.
92
+ private fresh = false;
93
+
94
+ private failures = 0;
95
+
96
+ // Connections in a row that a handler of ours ended. A start never clears
97
+ // it, since the next start can fail the same way.
98
+ private localErrors = 0;
99
+
100
+ private killPending = false;
101
+
102
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
103
+
104
+ // The terminal size the open connection's request asked for.
105
+ private requestedSize: { cols: number; rows: number } = { cols: 0, rows: 0 };
106
+
107
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the host holds a live promise
108
+ constructor(port: ImpPort, start: ImpSessionRequest, host: ImpHarnessHost) {
109
+ this.port = port;
110
+ this.host = host;
111
+ this.name = start.name;
112
+ this.session = start.session;
113
+ this.cols = start.cols;
114
+ this.rows = start.rows;
115
+
116
+ if (host.ready === undefined) {
117
+ this.openConnection(start);
118
+ } else {
119
+ void this.startWhenReady(host.ready, start);
120
+ }
121
+ }
122
+
123
+ readonly onData = (listener: (data: string) => void) => {
124
+ this.dataListeners.add(listener);
125
+
126
+ return {
127
+ dispose: () => {
128
+ this.dataListeners.delete(listener);
129
+ },
130
+ };
131
+ };
132
+
133
+ readonly onExit = (listener: (exit: HarnessExit) => void) => {
134
+ this.exitListeners.add(listener);
135
+
136
+ return {
137
+ dispose: () => {
138
+ this.exitListeners.delete(listener);
139
+ },
140
+ };
141
+ };
142
+
143
+ readonly onAttachment = (listener: (attachment: HarnessAttachment) => void) => {
144
+ this.attachmentListeners.add(listener);
145
+
146
+ return {
147
+ dispose: () => {
148
+ this.attachmentListeners.delete(listener);
149
+ },
150
+ };
151
+ };
152
+
153
+ readonly write = (data: string): void => {
154
+ const bytes = new TextEncoder().encode(data);
155
+
156
+ if (this.started && this.connection !== null) {
157
+ this.connection.write(bytes);
158
+ } else if (!this.done) {
159
+ this.pending.push(bytes);
160
+ }
161
+ };
162
+
163
+ readonly resize = (cols: number, rows: number): void => {
164
+ this.cols = cols;
165
+ this.rows = rows;
166
+ this.connection?.resize(cols, rows);
167
+ };
168
+
169
+ // A kill between connections goes out once the next one starts.
170
+ readonly kill = (): void => {
171
+ if (this.started && this.connection !== null) {
172
+ this.connection.sendSignal('SIGHUP');
173
+ } else if (!this.done) {
174
+ this.killPending = true;
175
+ }
176
+ };
177
+
178
+ readonly detach = (): void => {
179
+ if (this.done) {
180
+ return;
181
+ }
182
+
183
+ const connection = this.connection;
184
+
185
+ this.stopFollowing();
186
+ connection?.close();
187
+ };
188
+
189
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
190
+ private async startWhenReady(ready: Promise<void>, start: ImpSessionRequest): Promise<void> {
191
+ try {
192
+ await ready;
193
+ } catch (error) {
194
+ if (!this.done) {
195
+ this.emitExit({
196
+ exitCode: 1,
197
+ reason: 'ended',
198
+ detail: `imp refused the report socket (${error instanceof Error ? error.message : String(error)})`,
199
+ });
200
+ }
201
+
202
+ return;
203
+ }
204
+
205
+ // A resize while the host readied changed the size the start asks for.
206
+ if (!this.done) {
207
+ this.openConnection({ ...start, cols: this.cols, rows: this.rows });
208
+ }
209
+ }
210
+
211
+ private openConnection(request: ImpSessionRequest): void {
212
+ const connection = this.port.openSession(request, {
213
+ onStarted: (started) => {
214
+ if (this.connection === connection) {
215
+ this.applyStarted(connection, started);
216
+ }
217
+ },
218
+ onOutput: (data) => {
219
+ if (this.connection === connection) {
220
+ this.applyOutput(data);
221
+ }
222
+ },
223
+ });
224
+
225
+ this.connection = connection;
226
+ this.started = false;
227
+ this.requestedSize = { cols: request.cols, rows: request.rows };
228
+ void this.waitForOutcome(connection);
229
+ }
230
+
231
+ private applyStarted(connection: ImpSessionConnection, started: ImpSessionStarted): void {
232
+ const output = started.output;
233
+ const offsets = this.host.offsets && output.continuity === 'offsets' ? output : null;
234
+
235
+ if (offsets?.resume?.kind === 'gap') {
236
+ this.restartFreshAttach(connection);
237
+
238
+ return;
239
+ }
240
+
241
+ if (offsets?.resume?.kind === 'generation_changed') {
242
+ this.emitReplacedExit(connection, offsets.previous);
243
+
244
+ return;
245
+ }
246
+
247
+ if (offsets === null || offsets.resume === undefined) {
248
+ if (this.fresh) {
249
+ this.decoder = new TextDecoder();
250
+
251
+ this.emitData(SCREEN_RESET);
252
+ }
253
+
254
+ this.highWater = offsets?.offset ?? 0;
255
+ }
256
+
257
+ this.cursor =
258
+ offsets === null ? null : { bootId: offsets.bootId, generation: offsets.executionGeneration };
259
+
260
+ this.nextOffset = offsets?.offset ?? 0;
261
+ this.preludeLeft = offsets?.prelude ?? 0;
262
+ this.fresh = false;
263
+ this.failures = 0;
264
+ this.started = true;
265
+
266
+ this.emitAttachment('attached');
267
+
268
+ // impd drops a resize that reaches it before the session starts, so the
269
+ // size asked for since the request goes out now.
270
+ if (this.requestedSize.cols !== this.cols || this.requestedSize.rows !== this.rows) {
271
+ connection.resize(this.cols, this.rows);
272
+ }
273
+
274
+ for (const bytes of this.pending) {
275
+ connection.write(bytes);
276
+ }
277
+
278
+ this.pending = [];
279
+
280
+ if (this.killPending) {
281
+ this.killPending = false;
282
+
283
+ connection.sendSignal('SIGHUP');
284
+ }
285
+ }
286
+
287
+ // The mode bytes ahead of a fresh replay go to the screen uncounted; every
288
+ // byte after them sits at an offset, and one below the high-water mark is
289
+ // a repeat the daemon already has.
290
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- output bytes have no readonly form
291
+ private applyOutput(data: Uint8Array): void {
292
+ let bytes = data;
293
+
294
+ if (this.preludeLeft > 0) {
295
+ const prelude = bytes.subarray(0, this.preludeLeft);
296
+
297
+ this.preludeLeft -= prelude.length;
298
+
299
+ this.emitData(this.decoder.decode(prelude, { stream: true }));
300
+
301
+ bytes = bytes.subarray(prelude.length);
302
+ }
303
+
304
+ if (this.cursor !== null) {
305
+ const start = this.nextOffset;
306
+
307
+ this.nextOffset += bytes.length;
308
+ bytes = bytes.subarray(Math.max(0, this.highWater - start));
309
+ this.highWater = Math.max(this.highWater, this.nextOffset);
310
+ }
311
+
312
+ if (bytes.length > 0) {
313
+ this.emitData(this.decoder.decode(bytes, { stream: true }));
314
+ }
315
+ }
316
+
317
+ private async waitForOutcome(connection: ImpSessionConnection): Promise<void> {
318
+ const outcome = await connection.outcome;
319
+
320
+ if (this.connection !== connection || this.done || this.host.isSuspending()) {
321
+ return;
322
+ }
323
+
324
+ const wasStarted = this.started;
325
+
326
+ this.connection = null;
327
+ this.started = false;
328
+
329
+ this.applyOutcome(outcome, wasStarted);
330
+ }
331
+
332
+ // A drop resumes at once only on a connection that had started, so a
333
+ // connection impd drops before it answers counts as a failed reconnect.
334
+ private applyOutcome(outcome: ImpSessionOutcome, wasStarted: boolean): void {
335
+ if (outcome.kind === 'local_error') {
336
+ this.applyLocalError(outcome);
337
+
338
+ return;
339
+ }
340
+
341
+ this.localErrors = 0;
342
+
343
+ if (outcome.kind === 'exit') {
344
+ this.emitExit({ exitCode: outcome.code ?? 1, reason: 'exited' });
345
+
346
+ return;
347
+ }
348
+
349
+ if (outcome.kind === 'failed') {
350
+ this.applyRefusal(outcome);
351
+
352
+ return;
353
+ }
354
+
355
+ if (outcome.kind === 'unauthorized' || outcome.kind === 'bad_message') {
356
+ this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
357
+
358
+ return;
359
+ }
360
+
361
+ // impd closes a connection whose send it dropped under backpressure
362
+ // with 1011, and detaches a slow one: either resumes at once.
363
+ const isDropped =
364
+ (outcome.kind === 'closed' && outcome.closeCode === 1011) ||
365
+ (outcome.kind === 'detached' && outcome.reason === 'slow');
366
+
367
+ const delay = isDropped && wasStarted ? 0 : null;
368
+
369
+ this.scheduleReconnect(outcome, delay);
370
+ }
371
+
372
+ // A handler that throws on every connection would reconnect without end,
373
+ // so these failures take the host's delays on their own count and end the
374
+ // harness once they run out.
375
+ private applyLocalError(outcome: Extract<ImpSessionOutcome, { kind: 'local_error' }>): void {
376
+ const delay = this.host.reconnectDelaysMs[this.localErrors];
377
+
378
+ if (delay === undefined) {
379
+ this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
380
+
381
+ return;
382
+ }
383
+
384
+ this.localErrors += 1;
385
+
386
+ this.scheduleReconnect(outcome, delay);
387
+ }
388
+
389
+ private applyRefusal(outcome: Extract<ImpSessionOutcome, { kind: 'failed' }>): void {
390
+ if (outcome.code === 'INVALID_RESUME') {
391
+ this.fresh = true;
392
+ this.cursor = null;
393
+
394
+ this.scheduleReconnect(outcome, 0);
395
+
396
+ return;
397
+ }
398
+
399
+ if (outcome.code === 'NO_SESSION') {
400
+ this.emitExit(buildLostExit(outcome.data, this.cursor));
401
+
402
+ return;
403
+ }
404
+
405
+ if (outcome.code === 'INVALID_STATE') {
406
+ const state = isRecord(outcome.data) ? outcome.data['state'] : undefined;
407
+
408
+ const exit: HarnessExit =
409
+ state === 'sleeping'
410
+ ? { exitCode: 0, reason: 'suspended' }
411
+ : {
412
+ exitCode: 1,
413
+ reason: 'ended',
414
+ detail: `imp is ${typeof state === 'string' ? state : 'not running'}`,
415
+ };
416
+
417
+ this.emitExit(exit);
418
+
419
+ return;
420
+ }
421
+
422
+ this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
423
+ }
424
+
425
+ // A delay of null takes the next one from the host's list; running out of
426
+ // them ends the harness with what ended its last connection.
427
+ private scheduleReconnect(
428
+ outcome: Exclude<ImpSessionOutcome, { kind: 'exit' }>,
429
+ delay: number | null,
430
+ ): void {
431
+ const wait = delay ?? this.host.reconnectDelaysMs[this.failures];
432
+
433
+ if (wait === undefined) {
434
+ this.emitExit({ exitCode: 1, reason: 'ended', detail: formatOutcome(outcome) });
435
+
436
+ return;
437
+ }
438
+
439
+ if (delay === null) {
440
+ this.failures += 1;
441
+ }
442
+
443
+ this.emitAttachment('reattaching');
444
+
445
+ this.reconnectTimer = setTimeout(() => {
446
+ this.reconnectTimer = null;
447
+
448
+ if (!this.done) {
449
+ this.openConnection(this.buildAttach());
450
+ }
451
+ }, wait);
452
+
453
+ this.reconnectTimer.unref();
454
+ }
455
+
456
+ // A reconnect never wakes the imp: a sleeping imp answers that it sleeps,
457
+ // and only a revive wakes it.
458
+ private buildAttach(): ImpSessionRequest {
459
+ const cursor = this.fresh ? null : this.cursor;
460
+
461
+ if (cursor === null) {
462
+ this.fresh = true;
463
+ }
464
+
465
+ return {
466
+ kind: 'attach',
467
+ name: this.name,
468
+ session: this.session,
469
+ cols: this.cols,
470
+ rows: this.rows,
471
+ wake: false,
472
+ ...(cursor === null
473
+ ? {}
474
+ : { resumeFrom: { executionGeneration: cursor.generation, offset: this.highWater } }),
475
+ };
476
+ }
477
+
478
+ // Bytes after a gap can start inside an escape sequence, so the screen
479
+ // takes a fresh attach instead. The old connection stops counting first,
480
+ // so its close is not taken for a drop.
481
+ private restartFreshAttach(connection: ImpSessionConnection): void {
482
+ this.connection = null;
483
+
484
+ connection.close();
485
+
486
+ this.fresh = true;
487
+
488
+ this.openConnection(this.buildAttach());
489
+ }
490
+
491
+ // A generation under the session's name other than its own is not the
492
+ // daemon's harness: the harness ended, with its exit code when impd kept
493
+ // it.
494
+ private emitReplacedExit(
495
+ connection: ImpSessionConnection,
496
+ previous: PreviousGeneration | undefined,
497
+ ): void {
498
+ const exit: HarnessExit =
499
+ previous !== undefined && previous.executionGeneration === this.cursor?.generation
500
+ ? { exitCode: previous.exitCode ?? 1, reason: 'exited' }
501
+ : { exitCode: 1, reason: 'ended', detail: 'imp replaced the process' };
502
+
503
+ this.connection = null;
504
+
505
+ connection.close();
506
+ this.emitExit(exit);
507
+ }
508
+
509
+ private emitData(text: string): void {
510
+ for (const listener of this.dataListeners) {
511
+ listener(text);
512
+ }
513
+ }
514
+
515
+ private emitAttachment(attachment: HarnessAttachment): void {
516
+ for (const listener of this.attachmentListeners) {
517
+ listener(attachment);
518
+ }
519
+ }
520
+
521
+ private emitExit(exit: HarnessExit): void {
522
+ const listeners = [...this.exitListeners];
523
+
524
+ this.stopFollowing();
525
+
526
+ for (const listener of listeners) {
527
+ listener(exit);
528
+ }
529
+ }
530
+
531
+ private stopFollowing(): void {
532
+ if (this.reconnectTimer !== null) {
533
+ clearTimeout(this.reconnectTimer);
534
+
535
+ this.reconnectTimer = null;
536
+ }
537
+
538
+ this.done = true;
539
+ this.connection = null;
540
+
541
+ this.dataListeners.clear();
542
+ this.exitListeners.clear();
543
+ this.attachmentListeners.clear();
544
+ this.host.onDone();
545
+ }
546
+ }
547
+
548
+ // What ended a process impd no longer holds. In the same boot, the kept
549
+ // previous generation gives its exit. Across a cold boot, the first boot
550
+ // after the daemon's own gives the cause; without the daemon's boot among
551
+ // impd's last cold boots, the cause is unknown.
552
+ function buildLostExit(
553
+ data: unknown,
554
+ cursor: Readonly<{ bootId: string; generation: string }> | null,
555
+ ): HarnessExit {
556
+ const record = isRecord(data) ? data : {};
557
+
558
+ if (cursor !== null && record['bootId'] === cursor.bootId) {
559
+ const previous = record['previous'];
560
+ const isOwn = isRecord(previous) && previous['executionGeneration'] === cursor.generation;
561
+ const exitCode = isOwn && typeof previous['exitCode'] === 'number' ? previous['exitCode'] : 1;
562
+
563
+ return isOwn
564
+ ? { exitCode, reason: 'exited' }
565
+ : { exitCode: 1, reason: 'ended', detail: 'ended, cause unknown' };
566
+ }
567
+
568
+ const boots = collectColdBoots(record['coldBoots']);
569
+ const own = cursor === null ? -1 : boots.findIndex((boot) => boot.bootId === cursor.bootId);
570
+ const next = own > 0 ? boots[own - 1] : undefined;
571
+
572
+ return next === undefined
573
+ ? { exitCode: 1, reason: 'ended', detail: 'ended, cause unknown' }
574
+ : { exitCode: 1, reason: 'ended', detail: `imp rebooted (${next.cause})` };
575
+ }
576
+
577
+ // impd's cold boots, newest first, as far as each one reads.
578
+ function collectColdBoots(value: unknown): { readonly bootId: unknown; readonly cause: string }[] {
579
+ if (!Array.isArray(value)) {
580
+ return [];
581
+ }
582
+
583
+ return value
584
+ .filter((boot) => isRecord(boot))
585
+ .map((boot) => ({
586
+ bootId: boot['bootId'],
587
+ cause: typeof boot['cause'] === 'string' ? boot['cause'] : 'unknown',
588
+ }));
589
+ }
590
+
591
+ // An end other than an exit, as a session's last message.
592
+ function formatOutcome(outcome: Exclude<ImpSessionOutcome, { kind: 'exit' }>): string {
593
+ if (outcome.kind === 'failed') {
594
+ return `imp refused the session (${outcome.code ?? 'error'})`;
595
+ }
596
+
597
+ if (outcome.kind === 'detached') {
598
+ return `imp detached the session (${outcome.reason})`;
599
+ }
600
+
601
+ if (outcome.kind === 'closed') {
602
+ return `imp connection closed (${outcome.reason})`;
603
+ }
604
+
605
+ if (outcome.kind === 'unreachable') {
606
+ return 'imp unreachable';
607
+ }
608
+
609
+ if (outcome.kind === 'unauthorized') {
610
+ return 'imp refused the token';
611
+ }
612
+
613
+ if (outcome.kind === 'local_error') {
614
+ return `imp connection failed in the daemon (${outcome.detail})`;
615
+ }
616
+
617
+ return 'imp sent a bad message';
618
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A call impd refused or could not serve, with impd's error code (`LEASED`,
3
+ * `NOT_FOUND`, `FORBIDDEN`, and the rest) and the data that code defines.
4
+ * A transport failure carries `UNREACHABLE` or `UNAUTHORIZED`.
5
+ */
6
+ export class ImpPortError extends Error {
7
+ readonly code: string;
8
+
9
+ readonly data: unknown;
10
+
11
+ constructor(code: string, message: string, data?: unknown) {
12
+ super(message);
13
+
14
+ this.code = code;
15
+ this.data = data;
16
+ this.name = 'ImpPortError';
17
+ }
18
+ }