@zgeoff/atc 3.4.2 → 3.5.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 (78) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/client/daemon-client.ts +6 -1
  6. package/src/collect-restart-env.ts +6 -3
  7. package/src/daemon/build-scoped-context.ts +2 -0
  8. package/src/daemon/check-session-scope.ts +235 -0
  9. package/src/daemon/daemon-connection.ts +74 -0
  10. package/src/daemon/daemon-context.ts +21 -0
  11. package/src/daemon/daemon.ts +21 -0
  12. package/src/daemon/find-host-branch.ts +28 -0
  13. package/src/daemon/materialize-workspace.ts +6 -1
  14. package/src/daemon/merge-recorded-scope.ts +50 -0
  15. package/src/daemon/published-records.ts +269 -0
  16. package/src/daemon/request-access-classes.ts +1 -0
  17. package/src/daemon/run-host-git.ts +52 -0
  18. package/src/daemon/sessions.ts +185 -53
  19. package/src/daemon/start-headless-turn.ts +3 -0
  20. package/src/federation/id-rules.ts +11 -2
  21. package/src/mcp/mcp-tools.ts +50 -0
  22. package/src/mcp/require-daemon-features.ts +1 -0
  23. package/src/mcp/run-tool.ts +14 -0
  24. package/src/protocol/daemon-features.ts +5 -0
  25. package/src/protocol/parse-declared-scope.ts +158 -0
  26. package/src/protocol/protocol.ts +1 -0
  27. package/src/protocol/published-record.ts +62 -0
  28. package/src/protocol/request-param-schemas.ts +16 -0
  29. package/src/store/parse-published-record.ts +59 -0
  30. package/src/store/run-migrations.ts +23 -0
  31. package/src/store/state-store.ts +32 -0
  32. package/src/test-utils/build-mock-published-record.ts +39 -0
  33. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  34. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  35. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  36. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  37. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  38. package/src/test-utils/build-stub-host-hold.ts +7 -6
  39. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  40. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  41. package/src/test-utils/build-stub-terminal.ts +3 -3
  42. package/src/test-utils/create-git-fixture.ts +2 -2
  43. package/src/test-utils/create-stub-imp-port.ts +37 -37
  44. package/src/test-utils/create-stub-systemd.ts +6 -6
  45. package/src/test-utils/isolate-home.ts +8 -5
  46. package/src/test-utils/read-query-plan.ts +10 -6
  47. package/src/test-utils/register-test-cleanup.ts +10 -10
  48. package/src/test-utils/setup-mcp-home.ts +7 -6
  49. package/src/test-utils/setup-mcp-http.ts +6 -6
  50. package/src/test-utils/setup-temp-dir.ts +4 -10
  51. package/src/test-utils/start-cut-proxy.ts +2 -4
  52. package/src/test-utils/start-daemon-process.ts +13 -13
  53. package/src/test-utils/start-git-http-server.ts +2 -4
  54. package/src/test-utils/start-mcp-stdio.ts +5 -5
  55. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  56. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  57. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  58. package/src/test-utils/start-stub-impd-info.ts +3 -7
  59. package/src/test-utils/start-stub-impd.ts +3 -5
  60. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  61. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  62. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  63. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  65. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  66. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  67. package/src/test-utils/start-stub-silent-server.ts +3 -6
  68. package/src/test-utils/start-stub-source-daemon.ts +2 -2
  69. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  70. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  71. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  72. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  73. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  74. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  75. package/src/test-utils/start-test-daemon.ts +62 -18
  76. package/src/test-utils/start-tui-harness.ts +17 -10
  77. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  78. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
@@ -39,8 +39,8 @@ interface MCPToolResult {
39
39
  * with the new session's id. A request still unanswered when the server's
40
40
  * stdout ends rejects. When `initialize` fails, the server is stopped
41
41
  * before the start rejects. The server stops once the current test
42
- * finishes, so it must run inside a test; disposal stops it sooner and waits
43
- * for it to exit, and a second disposal waits for the same exit. The daemon
42
+ * finishes, so it must run inside a test; `stop` stops it sooner and waits
43
+ * for it to exit, and a second stop waits for the same exit. The daemon
44
44
  * stays up for the home to stop.
45
45
  */
46
46
  export async function startMCPStdio(options: MCPStdioOptions) {
@@ -58,7 +58,7 @@ export async function startMCPStdio(options: MCPStdioOptions) {
58
58
  stderr: 'ignore',
59
59
  });
60
60
 
61
- const stopServer = registerTestCleanup(async (): Promise<void> => {
61
+ const stop = registerTestCleanup(async (): Promise<void> => {
62
62
  void proc.stdin.end();
63
63
  proc.kill();
64
64
 
@@ -113,7 +113,7 @@ export async function startMCPStdio(options: MCPStdioOptions) {
113
113
 
114
114
  return id;
115
115
  },
116
- [Symbol.asyncDispose]: stopServer,
116
+ stop,
117
117
  };
118
118
 
119
119
  // A failed initialize stops the server here, before the start rejects.
@@ -122,7 +122,7 @@ export async function startMCPStdio(options: MCPStdioOptions) {
122
122
  capabilities: {},
123
123
  clientInfo: { name: 'atc-test' },
124
124
  }).catch(async (error: unknown) => {
125
- await stopServer();
125
+ await stop();
126
126
 
127
127
  throw error;
128
128
  });
@@ -8,7 +8,7 @@ interface StubAnsweringListener {
8
8
 
9
9
  // The first read each connection sent, decoded, in arrival order.
10
10
  readonly lines: readonly string[];
11
- readonly [Symbol.asyncDispose]: () => Promise<void>;
11
+ readonly stop: () => Promise<void>;
12
12
  }
13
13
 
14
14
  /**
@@ -17,7 +17,7 @@ interface StubAnsweringListener {
17
17
  * read each one sends, answers that read with the line `answer`, and
18
18
  * leaves every later byte unread on the connection. Resolves once it
19
19
  * listens. Once the current test finishes, it destroys every connection it
20
- * accepted and closes, so it must run inside a test; disposal does so
20
+ * accepted and closes, so it must run inside a test; `stop` does so
21
21
  * sooner and resolves once the server has closed, and a second stop does
22
22
  * nothing.
23
23
  */
@@ -60,6 +60,6 @@ export async function startStubAnsweringListener(path: string): Promise<StubAnsw
60
60
  return {
61
61
  peers,
62
62
  lines,
63
- [Symbol.asyncDispose]: stop,
63
+ stop,
64
64
  };
65
65
  }
@@ -1,13 +1,13 @@
1
1
  import { registerTestCleanup } from './register-test-cleanup';
2
2
 
3
3
  interface StubClosingListener {
4
- readonly [Symbol.dispose]: () => void;
4
+ readonly stop: () => void;
5
5
  }
6
6
 
7
7
  /**
8
8
  * A stand-in for a daemon that hangs up: it listens on the unix socket path
9
9
  * and ends every connection as soon as it opens, reading nothing. It stops
10
- * once the current test finishes, so it must run inside a test; disposal
10
+ * once the current test finishes, so it must run inside a test; `stop`
11
11
  * stops it sooner, and a second stop does nothing.
12
12
  */
13
13
  export function startStubClosingListener(path: string): StubClosingListener {
@@ -26,6 +26,6 @@ export function startStubClosingListener(path: string): StubClosingListener {
26
26
  });
27
27
 
28
28
  return {
29
- [Symbol.dispose]: stop,
29
+ stop,
30
30
  };
31
31
  }
@@ -25,8 +25,8 @@ interface DroppingDaemonOptions {
25
25
  * idempotency key each of those requests carried, `undefined` for one that
26
26
  * carried none. `reads` counts the reads it has taken from every connection,
27
27
  * so a test can wait until one piece of a split write has arrived. It stops
28
- * once the current test finishes, so it must run inside a test; `stop` or
29
- * disposal stops it sooner, and a second stop does nothing.
28
+ * once the current test finishes, so it must run inside a test; `stop`
29
+ * stops it sooner, and a second stop does nothing.
30
30
  */
31
31
  export function startStubDroppingDaemon(socketPath: string, options: DroppingDaemonOptions) {
32
32
  const keys: unknown[] = [];
@@ -100,6 +100,5 @@ export function startStubDroppingDaemon(socketPath: string, options: DroppingDae
100
100
  return reads;
101
101
  },
102
102
  stop,
103
- [Symbol.dispose]: stop,
104
103
  };
105
104
  }
@@ -6,9 +6,8 @@ import { registerTestCleanup } from './register-test-cleanup';
6
6
  * `{ json: info }`, so a client reads `info.features` as impd's features;
7
7
  * the test sets `info.features` to what impd should report. `paths` holds
8
8
  * the request path of each call, in order. The server stops once the
9
- * current test finishes, so it must run inside a test; disposal stops it
10
- * sooner, the asynchronous form resolving once it has stopped, and a second
11
- * stop does nothing.
9
+ * current test finishes, so it must run inside a test; `stop` stops it
10
+ * sooner and resolves once it has stopped, and a second stop does nothing.
12
11
  */
13
12
  export function startStubImpdInfo() {
14
13
  const paths: string[] = [];
@@ -30,9 +29,6 @@ export function startStubImpdInfo() {
30
29
  url: `http://127.0.0.1:${String(server.port)}`,
31
30
  paths,
32
31
  info,
33
- [Symbol.dispose]: () => {
34
- void stop();
35
- },
36
- [Symbol.asyncDispose]: stop,
32
+ stop,
37
33
  };
38
34
  }
@@ -18,8 +18,8 @@ type StubExecReply = 'refuse' | 'count' | 'exit-early';
18
18
  * whose broker is not ready by default. It takes no WebSocket message over
19
19
  * 2 MiB, as impd refuses one over its own limit. The server stops, dropping
20
20
  * every open connection at once, when the current test finishes, so it must
21
- * run inside a test; disposal stops it sooner, and a second stop does
22
- * nothing.
21
+ * run inside a test; `stop` stops it sooner and resolves once it has
22
+ * stopped, and a second stop does nothing.
23
23
  */
24
24
  export function startStubImpd() {
25
25
  const authorizations: (string | null)[] = [];
@@ -136,8 +136,6 @@ export function startStubImpd() {
136
136
  controls,
137
137
  execOpens,
138
138
  exec,
139
- [Symbol.dispose]: () => {
140
- void stop();
141
- },
139
+ stop,
142
140
  };
143
141
  }
@@ -47,8 +47,8 @@ interface StubLegacyDaemonOptions {
47
47
  * `connections` counts the connections it accepted and those still open.
48
48
  * `port` holds the TCP port it bound, `null` on a unix socket. It stops
49
49
  * once the current test finishes, so it must run inside a test, unless the
50
- * options make the caller its owner; `stop` or disposal stops it sooner, and
51
- * a second stop does nothing.
50
+ * options make the caller its owner; `stop` stops it sooner, and a second
51
+ * stop does nothing.
52
52
  */
53
53
  export function startStubLegacyDaemon(
54
54
  address: string | StubLegacyDaemonTCPAddress,
@@ -135,7 +135,6 @@ export function startStubLegacyDaemon(
135
135
  connections,
136
136
  port: server.port,
137
137
  stop,
138
- [Symbol.dispose]: stop,
139
138
  };
140
139
  }
141
140
 
@@ -14,7 +14,7 @@ interface StubMCPServer {
14
14
  // Every request the stand-in received, in arrival order.
15
15
  readonly requests: readonly StubMCPRequest[];
16
16
 
17
- readonly [Symbol.dispose]: () => void;
17
+ readonly stop: () => Promise<void>;
18
18
  }
19
19
 
20
20
  /**
@@ -22,7 +22,7 @@ interface StubMCPServer {
22
22
  * request with the JSON body given, and records each request's method,
23
23
  * path, authorization and content-type headers, and JSON body. `url` holds
24
24
  * its origin. It stops once the current test finishes, so it must run
25
- * inside a test; disposal stops it sooner, and a second stop does nothing.
25
+ * inside a test; `stop` stops it sooner, and a second stop does nothing.
26
26
  */
27
27
  export function startStubMCPServer(answer: unknown): StubMCPServer {
28
28
  const requests: StubMCPRequest[] = [];
@@ -48,8 +48,6 @@ export function startStubMCPServer(answer: unknown): StubMCPServer {
48
48
  return {
49
49
  url: `http://127.0.0.1:${String(server.port)}`,
50
50
  requests,
51
- [Symbol.dispose]: () => {
52
- void stop();
53
- },
51
+ stop,
54
52
  };
55
53
  }
@@ -7,7 +7,7 @@ interface StubRecordingListener {
7
7
 
8
8
  // What each read took from any connection, decoded, in arrival order.
9
9
  readonly received: readonly string[];
10
- readonly [Symbol.dispose]: () => void;
10
+ readonly stop: () => void;
11
11
  }
12
12
 
13
13
  /**
@@ -15,7 +15,7 @@ interface StubRecordingListener {
15
15
  * socket path, hands the test the server's side of the first connection it
16
16
  * accepts, records what each read takes from any connection, and sends
17
17
  * nothing of its own. It stops, dropping every connection it holds, once
18
- * the current test finishes, so it must run inside a test; disposal stops it
18
+ * the current test finishes, so it must run inside a test; `stop` stops it
19
19
  * sooner, and a second stop does nothing.
20
20
  */
21
21
  export function startStubRecordingListener(path: string): StubRecordingListener {
@@ -42,6 +42,6 @@ export function startStubRecordingListener(path: string): StubRecordingListener
42
42
  return {
43
43
  accepted: accepted.promise,
44
44
  received,
45
- [Symbol.dispose]: stop,
45
+ stop,
46
46
  };
47
47
  }
@@ -6,7 +6,7 @@ interface StubRefusingListener {
6
6
  // What each read took from any connection, decoded, in arrival order.
7
7
  readonly received: readonly string[];
8
8
 
9
- readonly [Symbol.dispose]: () => void;
9
+ readonly stop: () => void;
10
10
  }
11
11
 
12
12
  /**
@@ -14,7 +14,7 @@ interface StubRefusingListener {
14
14
  * line is not a handshake: on a loopback port, it records what each read
15
15
  * takes from a connection and ends that connection, answering nothing.
16
16
  * It stops once the current test finishes, so it must run inside a test;
17
- * disposal stops it sooner, and a second stop does nothing.
17
+ * `stop` stops it sooner, and a second stop does nothing.
18
18
  */
19
19
  export function startStubRefusingListener(): StubRefusingListener {
20
20
  const received: string[] = [];
@@ -38,6 +38,6 @@ export function startStubRefusingListener(): StubRefusingListener {
38
38
  return {
39
39
  port: server.port,
40
40
  received,
41
- [Symbol.dispose]: stop,
41
+ stop,
42
42
  };
43
43
  }
@@ -9,8 +9,7 @@ interface StubReporterSocket {
9
9
  // can wait until one piece of a split write has arrived.
10
10
  readonly reads: number;
11
11
  readonly waitForLine: (timeoutMs?: number) => Promise<string>;
12
- readonly [Symbol.dispose]: () => void;
13
- readonly [Symbol.asyncDispose]: () => Promise<void>;
12
+ readonly stop: () => void;
14
13
  }
15
14
 
16
15
  /**
@@ -21,7 +20,7 @@ interface StubReporterSocket {
21
20
  * connection for the sender to close. `waitForLine` resolves with the first
22
21
  * line once one has arrived, and rejects naming the socket when none arrives
23
22
  * within `timeoutMs`, 5 seconds by default. The listener stops once the
24
- * current test finishes, so it must run inside a test; disposal stops it
23
+ * current test finishes, so it must run inside a test; `stop` stops it
25
24
  * sooner, and a second stop does nothing.
26
25
  */
27
26
  export function startStubReporterSocket(path: string): StubReporterSocket {
@@ -69,11 +68,6 @@ export function startStubReporterSocket(path: string): StubReporterSocket {
69
68
  },
70
69
  { timeoutMs },
71
70
  ),
72
- [Symbol.dispose]: stop,
73
- [Symbol.asyncDispose]: () => {
74
- stop();
75
-
76
- return Promise.resolve();
77
- },
71
+ stop,
78
72
  };
79
73
  }
@@ -9,7 +9,7 @@ import { registerTestCleanup } from './register-test-cleanup';
9
9
  * onto `lists`, and withholds the answer to any request that finds `lists`
10
10
  * empty. `methods` holds the method of each request it took, in order.
11
11
  * The listener stops once the current test finishes, so it must run inside
12
- * a test; disposal stops it sooner, and a second stop does nothing.
12
+ * a test; `stop` stops it sooner, and a second stop does nothing.
13
13
  */
14
14
  export function startStubRestoreDaemon(socketPath: string) {
15
15
  const lists: Readonly<Record<string, unknown>>[] = [];
@@ -49,11 +49,6 @@ export function startStubRestoreDaemon(socketPath: string) {
49
49
  return {
50
50
  lists,
51
51
  methods,
52
- [Symbol.dispose]: stop,
53
- [Symbol.asyncDispose]: () => {
54
- stop();
55
-
56
- return Promise.resolve();
57
- },
52
+ stop,
58
53
  };
59
54
  }
@@ -16,8 +16,7 @@ interface StubSessionBridge {
16
16
  // How many reads the stand-in has taken from its connections, so a test
17
17
  // can wait until one piece of a split write has arrived.
18
18
  readonly reads: number;
19
- readonly [Symbol.dispose]: () => void;
20
- readonly [Symbol.asyncDispose]: () => Promise<void>;
19
+ readonly stop: () => void;
21
20
  }
22
21
 
23
22
  /**
@@ -28,7 +27,7 @@ interface StubSessionBridge {
28
27
  * does once it attaches the tap, and hands every other request to the
29
28
  * responder, writing back each line it returns. A line that is not a JSON
30
29
  * object reaches the responder as an empty request. The listener stops once
31
- * the current test finishes, so it must run inside a test; disposal stops it
30
+ * the current test finishes, so it must run inside a test; `stop` stops it
32
31
  * sooner, and a second stop does nothing.
33
32
  */
34
33
  export function startStubSessionBridge(path: string, respond: BridgeResponder): StubSessionBridge {
@@ -80,12 +79,7 @@ export function startStubSessionBridge(path: string, respond: BridgeResponder):
80
79
  get reads() {
81
80
  return reads;
82
81
  },
83
- [Symbol.dispose]: stop,
84
- [Symbol.asyncDispose]: () => {
85
- stop();
86
-
87
- return Promise.resolve();
88
- },
82
+ stop,
89
83
  };
90
84
  }
91
85
 
@@ -5,8 +5,8 @@ import { registerTestCleanup } from './register-test-cleanup';
5
5
  * the loopback address, takes every request, and never answers one. `paths`
6
6
  * holds the request path of each request it took, in order. The server
7
7
  * stops, dropping the connections it holds, once the current test finishes,
8
- * so it must run inside a test; disposal stops it sooner, the asynchronous
9
- * form resolving once it has stopped, and a second stop does nothing.
8
+ * so it must run inside a test; `stop` stops it sooner and resolves once it
9
+ * has stopped, and a second stop does nothing.
10
10
  */
11
11
  export function startStubSilentServer() {
12
12
  const paths: string[] = [];
@@ -30,9 +30,6 @@ export function startStubSilentServer() {
30
30
  return {
31
31
  url: `http://127.0.0.1:${String(server.port)}/`,
32
32
  paths,
33
- [Symbol.dispose]: () => {
34
- void stop();
35
- },
36
- [Symbol.asyncDispose]: stop,
33
+ stop,
37
34
  };
38
35
  }
@@ -10,7 +10,7 @@ import { registerTestCleanup } from './register-test-cleanup';
10
10
  * `none`; `ATC_TEST_FIXTURE_URL` holds the repository the fixture source
11
11
  * lists, and `ATC_TEST_SOURCE_LOG` a file it appends each listing to.
12
12
  * The process stops once the current test finishes, so it must run inside
13
- * a test; disposal stops it sooner, and a second stop does nothing.
13
+ * a test; `stop` stops it sooner, and a second stop does nothing.
14
14
  */
15
15
  export async function startStubSourceDaemon(env: Readonly<Record<string, string | undefined>>) {
16
16
  const daemon = Bun.spawn([process.execPath, join(import.meta.dir, 'run-stub-source-daemon.ts')], {
@@ -37,5 +37,5 @@ export async function startStubSourceDaemon(env: Readonly<Record<string, string
37
37
  throw new Error('the source daemon exited before it listened');
38
38
  }
39
39
 
40
- return { pid: daemon.pid, [Symbol.asyncDispose]: stop };
40
+ return { pid: daemon.pid, stop };
41
41
  }
@@ -8,7 +8,7 @@ interface StubStalledClient {
8
8
 
9
9
  // How many bytes have arrived on the connection and wait unread.
10
10
  readonly countUnreadBytes: () => number;
11
- readonly [Symbol.dispose]: () => void;
11
+ readonly stop: () => void;
12
12
  }
13
13
 
14
14
  /**
@@ -18,7 +18,7 @@ interface StubStalledClient {
18
18
  * a promise that never resolves, so whatever the daemon sends it after
19
19
  * that backs up. Resolves once that first chunk arrives. The connection is
20
20
  * destroyed once the current test finishes, so it must run inside a test;
21
- * disposal destroys it sooner, and a second stop does nothing.
21
+ * `stop` destroys it sooner, and a second stop does nothing.
22
22
  */
23
23
  export async function startStubStalledClient(
24
24
  socketPath: string,
@@ -56,6 +56,6 @@ export async function startStubStalledClient(
56
56
  return {
57
57
  chunks,
58
58
  countUnreadBytes: () => socket.readableLength,
59
- [Symbol.dispose]: stop,
59
+ stop,
60
60
  };
61
61
  }
@@ -3,7 +3,7 @@ import type { Socket } from 'node:net';
3
3
  import { registerTestCleanup } from './register-test-cleanup';
4
4
 
5
5
  interface StubStalledListener {
6
- readonly [Symbol.dispose]: () => void;
6
+ readonly stop: () => void;
7
7
  }
8
8
 
9
9
  /**
@@ -11,7 +11,7 @@ interface StubStalledListener {
11
11
  * path and accepts every connection but never reads from it, so what the
12
12
  * kernel does not buffer stays queued on the sender's side. Resolves once
13
13
  * it listens. Once the current test finishes, it destroys every connection
14
- * it accepted and stops, so it must run inside a test; disposal does so
14
+ * it accepted and stops, so it must run inside a test; `stop` does so
15
15
  * sooner, and a second stop does nothing.
16
16
  */
17
17
  export async function startStubStalledListener(path: string): Promise<StubStalledListener> {
@@ -39,6 +39,6 @@ export async function startStubStalledListener(path: string): Promise<StubStalle
39
39
  });
40
40
 
41
41
  return {
42
- [Symbol.dispose]: stop,
42
+ stop,
43
43
  };
44
44
  }
@@ -12,7 +12,7 @@ interface StubTCPDaemon {
12
12
  // can wait until one piece of a split write has arrived before it sends
13
13
  // the next.
14
14
  readonly reads: number;
15
- readonly [Symbol.dispose]: () => void;
15
+ readonly stop: () => void;
16
16
  }
17
17
 
18
18
  // The two request fields the stand-in reads; every other field passes
@@ -25,7 +25,7 @@ const REQUEST = z.object({ id: z.number(), m: z.string() });
25
25
  * the request's method, recording every method in `seen`. It sends no
26
26
  * handshake of its own and checks no token. `reads` counts the reads it has
27
27
  * taken from every connection. It stops once the current test finishes, so
28
- * it must run inside a test; disposal stops it sooner, and a second stop
28
+ * it must run inside a test; `stop` stops it sooner, and a second stop
29
29
  * does nothing.
30
30
  */
31
31
  export function startStubTCPDaemon(): StubTCPDaemon {
@@ -70,6 +70,6 @@ export function startStubTCPDaemon(): StubTCPDaemon {
70
70
  get reads() {
71
71
  return reads;
72
72
  },
73
- [Symbol.dispose]: stop,
73
+ stop,
74
74
  };
75
75
  }
@@ -5,7 +5,7 @@ import { registerTestCleanup } from './register-test-cleanup';
5
5
  * loopback address. A process whose `BETTER_AUTH_TELEMETRY_ENDPOINT` holds
6
6
  * `url` sends its telemetry here. Every request is answered with HTTP 204,
7
7
  * and `received` holds the URL of each request, in order. The server stops
8
- * once the current test finishes, so it must run inside a test; disposal
8
+ * once the current test finishes, so it must run inside a test; `stop`
9
9
  * stops it sooner and resolves once it has stopped, and a second stop does
10
10
  * nothing.
11
11
  */
@@ -27,6 +27,6 @@ export function startStubTelemetryCollector() {
27
27
  return {
28
28
  url: `http://127.0.0.1:${String(server.port)}/`,
29
29
  received,
30
- [Symbol.asyncDispose]: stop,
30
+ stop,
31
31
  };
32
32
  }
@@ -6,7 +6,7 @@ interface StubUnansweringListener {
6
6
  // What each read took from any connection, decoded, in arrival order.
7
7
  readonly received: readonly string[];
8
8
 
9
- readonly [Symbol.dispose]: () => void;
9
+ readonly stop: () => void;
10
10
  }
11
11
 
12
12
  /**
@@ -14,7 +14,7 @@ interface StubUnansweringListener {
14
14
  * on a loopback port, it records what each read takes from a connection,
15
15
  * sends nothing back, and keeps the connection open. It stops, dropping
16
16
  * every connection it holds, once the current test finishes, so it must run
17
- * inside a test; disposal stops it sooner, and a second stop does nothing.
17
+ * inside a test; `stop` stops it sooner, and a second stop does nothing.
18
18
  */
19
19
  export function startStubUnansweringListener(): StubUnansweringListener {
20
20
  const received: string[] = [];
@@ -36,6 +36,6 @@ export function startStubUnansweringListener(): StubUnansweringListener {
36
36
  return {
37
37
  port: server.port,
38
38
  received,
39
- [Symbol.dispose]: stop,
39
+ stop,
40
40
  };
41
41
  }
@@ -4,7 +4,7 @@ interface StubUnansweringUnixListener {
4
4
  // What each read took from any connection, decoded, in arrival order.
5
5
  readonly received: readonly string[];
6
6
 
7
- readonly [Symbol.dispose]: () => void;
7
+ readonly stop: () => void;
8
8
  }
9
9
 
10
10
  /**
@@ -12,7 +12,7 @@ interface StubUnansweringUnixListener {
12
12
  * it: it listens on the unix socket path, records what each read takes from
13
13
  * a connection, sends nothing back, and keeps the connection open. It
14
14
  * stops, dropping every connection it holds, once the current test
15
- * finishes, so it must run inside a test; disposal stops it sooner, and a
15
+ * finishes, so it must run inside a test; `stop` stops it sooner, and a
16
16
  * second stop does nothing.
17
17
  */
18
18
  export function startStubUnansweringUnixListener(path: string): StubUnansweringUnixListener {
@@ -33,6 +33,6 @@ export function startStubUnansweringUnixListener(path: string): StubUnansweringU
33
33
 
34
34
  return {
35
35
  received,
36
- [Symbol.dispose]: stop,
36
+ stop,
37
37
  };
38
38
  }
@@ -42,31 +42,67 @@ interface TestDaemonConfig {
42
42
  // The temp directory's name prefix.
43
43
  readonly prefix?: string;
44
44
  readonly options?: TestDaemonOptionsBuilder;
45
+
46
+ // Whether each boot opens the main client; it does unless this is false.
47
+ readonly mainClient?: boolean;
48
+ }
49
+
50
+ /**
51
+ * A running test daemon and the calls that drive it.
52
+ */
53
+ interface TestDaemon extends TestDaemonPaths {
54
+ readonly build: string;
55
+ readonly logs: string[];
56
+ readonly events: EventMsg[];
57
+ readonly daemon: DaemonHandle;
58
+ readonly client: DaemonClient;
59
+ readonly openClient: (hello?: Readonly<Record<string, unknown>>) => Promise<DaemonClient>;
60
+ readonly openTCPClient: () => Promise<DaemonClient>;
61
+ readonly sendHookLines: (...lines: readonly Readonly<Record<string, unknown>>[]) => Promise<void>;
62
+ readonly stop: () => Promise<void>;
63
+ readonly restart: (options?: TestDaemonOptionsBuilder) => Promise<void>;
64
+ readonly dispose: () => Promise<void>;
65
+ }
66
+
67
+ /**
68
+ * The harness a config starts: one that opens no main client has neither
69
+ * that client nor the events it would collect.
70
+ */
71
+ type StartedTestDaemon<Config extends TestDaemonConfig> = Config extends {
72
+ readonly mainClient: false;
45
73
  }
74
+ ? Omit<TestDaemon, 'client' | 'events'>
75
+ : TestDaemon;
46
76
 
47
77
  // The build string the daemon and every client the harness opens send in
48
78
  // their handshake.
49
79
  const BUILD = 'atc/test-build';
50
80
 
51
81
  /**
52
- * A real daemon in a fresh temp directory, with a client that has already
53
- * sent its handshake. The options builder chooses what the daemon wires;
54
- * the harness sets the paths and the build, and collects the daemon's log
55
- * lines unless the options set their own log. `events` collects every event
56
- * the main client receives, across restarts. `openClient` opens another
57
- * client over the unix socket and sends its handshake with the given
58
- * params; `openTCPClient` connects to the TCP listener and sends nothing, so
59
- * the test drives that handshake itself. `sendHookLines` writes reporter
60
- * lines to the reporter socket and resolves once the daemon has closed the
61
- * connection. `stop` closes every client and stops the daemon; `restart`
62
- * does the same, then boots on the same paths and state with the options
63
- * given or the last ones, and opens a new main client. Disposal stops what
64
- * is running and removes the directory. That disposal runs once the current
65
- * test finishes, so it must run inside a test; disposing sooner runs it
66
- * then, and a second disposal does nothing. A first boot that fails runs it
82
+ * A real daemon in a fresh temp directory, with a main client that has
83
+ * already sent its handshake unless the config turns that client off, which
84
+ * leaves every connection the daemon counts to the test. The options
85
+ * builder chooses what the daemon wires; the harness sets the paths and the
86
+ * build, and collects the daemon's log lines unless the options set their
87
+ * own log. `events` collects every event the main client receives, across
88
+ * restarts. `openClient` opens another client over the unix socket and
89
+ * sends its handshake with the given params; `openTCPClient` connects to
90
+ * the TCP listener and sends nothing, so the test drives that handshake
91
+ * itself. `sendHookLines` writes reporter lines to the reporter socket and
92
+ * resolves once the daemon has closed the connection. `stop` closes every
93
+ * client and stops the daemon; `restart` does the same, then boots on the
94
+ * same paths and state with the options given or the last ones, and opens
95
+ * a new main client unless the config turns it off. `dispose` stops what is
96
+ * running and removes the directory. It runs once the current test
97
+ * finishes, so the harness must start inside a test; calling it sooner runs
98
+ * it then, and a second call does nothing. A first boot that fails runs it
67
99
  * before the start rejects.
68
100
  */
69
- export async function startTestDaemon(config: TestDaemonConfig = {}) {
101
+ export function startTestDaemon<const Config extends TestDaemonConfig = TestDaemonConfig>(
102
+ config?: Config,
103
+ ): Promise<StartedTestDaemon<Config>>;
104
+
105
+ export async function startTestDaemon(config: TestDaemonConfig = {}): Promise<TestDaemon> {
70
106
  const tmp = setupTempDir(config.prefix ?? 'atc-test-daemon-');
71
107
 
72
108
  // Registered after the directory, so it releases first: the daemon stops
@@ -75,7 +111,7 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
75
111
 
76
112
  const dispose = registerTestCleanup(() => stack.disposeAsync());
77
113
 
78
- stack.use(tmp);
114
+ stack.defer(tmp.teardown);
79
115
 
80
116
  const paths: TestDaemonPaths = {
81
117
  dir: tmp.dir,
@@ -142,6 +178,10 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
142
178
 
143
179
  live = daemon;
144
180
 
181
+ if (config.mainClient === false) {
182
+ return { daemon, client: null };
183
+ }
184
+
145
185
  const client = await openClient();
146
186
 
147
187
  client.onEvent = (event) => {
@@ -168,6 +208,10 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
168
208
  return current.daemon;
169
209
  },
170
210
  get client(): DaemonClient {
211
+ if (current.client === null) {
212
+ throw new Error('the test daemon started without a main client');
213
+ }
214
+
171
215
  return current.client;
172
216
  },
173
217
  openClient,
@@ -224,6 +268,6 @@ export async function startTestDaemon(config: TestDaemonConfig = {}) {
224
268
 
225
269
  current = await boot();
226
270
  },
227
- [Symbol.asyncDispose]: dispose,
271
+ dispose,
228
272
  };
229
273
  }