@zgeoff/atc 3.3.1 → 3.4.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 (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +30 -17
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -1,5 +1,7 @@
1
1
  import { FFIType, dlopen, ptr } from 'bun:ffi';
2
2
  import { closeSync, writeFileSync } from 'node:fs';
3
+ import type { Clock } from './system-clock';
4
+ import { systemClock } from './system-clock';
3
5
 
4
6
  export interface DaemonLock {
5
7
  // Releases the lock; safe to call more than once.
@@ -24,11 +26,13 @@ const RETRY_MS = 50;
24
26
  * racing at the same instant cannot both win, and the kernel drops it when
25
27
  * the holding process dies, so a crashed daemon never blocks the next one.
26
28
  * The file is never removed: removing it would let a newcomer lock a fresh
27
- * inode while an older daemon still holds the unlinked one.
29
+ * inode while an older daemon still holds the unlinked one. The wait reads
30
+ * its deadline from `clock` and waits out each retry through it.
28
31
  */
29
32
  export async function claimDaemonLock(
30
33
  lockPath: string,
31
34
  waitMs: number,
35
+ clock: Clock = systemClock,
32
36
  ): Promise<DaemonLock | null> {
33
37
  writeFileSync(lockPath, '', { flag: 'a' });
34
38
 
@@ -39,16 +43,18 @@ export async function claimDaemonLock(
39
43
  throw new Error(`atc daemon: cannot open the lock file ${lockPath}`);
40
44
  }
41
45
 
42
- const deadline = Date.now() + waitMs;
46
+ const deadline = clock.now() + waitMs;
43
47
 
44
48
  while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
45
- if (Date.now() >= deadline) {
49
+ if (clock.now() >= deadline) {
46
50
  closeSync(fd);
47
51
 
48
52
  return null;
49
53
  }
50
54
 
51
- await Bun.sleep(RETRY_MS);
55
+ await new Promise<void>((resolve) => {
56
+ clock.schedule(resolve, RETRY_MS);
57
+ });
52
58
  }
53
59
 
54
60
  let held = true;
@@ -1,13 +1,13 @@
1
- import { homedir } from 'node:os';
2
1
  import { isRecord } from './report';
2
+ import { resolveHomeDir } from './resolve-home-dir';
3
3
 
4
4
  /**
5
5
  * Reads `dirs.roots`: the directories whose children the spawn picker
6
6
  * lists. A non-string entry is dropped, a leading `~` expands to the home
7
7
  * directory, and trailing slashes are trimmed so a root compares equal to
8
- * the paths under it.
8
+ * the paths under it. `home` is the directory a `~` expands to.
9
9
  */
10
- export function collectDirRoots(raw: unknown): readonly string[] {
10
+ export function collectDirRoots(raw: unknown, home: string = resolveHomeDir()): readonly string[] {
11
11
  if (!isRecord(raw) || !Array.isArray(raw['roots'])) {
12
12
  return [];
13
13
  }
@@ -19,14 +19,14 @@ export function collectDirRoots(raw: unknown): readonly string[] {
19
19
  continue;
20
20
  }
21
21
 
22
- roots.push(normalizeRoot(item));
22
+ roots.push(normalizeRoot(item, home));
23
23
  }
24
24
 
25
25
  return roots;
26
26
  }
27
27
 
28
- function normalizeRoot(dir: string): string {
29
- const expanded = dir === '~' ? homedir() : dir.replace(/^~\//u, `${homedir()}/`);
28
+ function normalizeRoot(dir: string, home: string): string {
29
+ const expanded = dir === '~' ? home : dir.replace(/^~\//u, `${home}/`);
30
30
  const trimmed = expanded.replace(/\/+$/u, '');
31
31
 
32
32
  return trimmed === '' ? '/' : trimmed;
@@ -1,6 +1,6 @@
1
- import { homedir } from 'node:os';
2
1
  import { z } from 'zod';
3
2
  import { isRecord } from './report';
3
+ import { resolveHomeDir } from './resolve-home-dir';
4
4
 
5
5
  /**
6
6
  * One configured hook: a shell command the daemon runs when the named wire
@@ -30,9 +30,10 @@ const HOOK_ENTRY_SCHEMA = z.object({
30
30
  * is left out; an event name with no valid entries is dropped. Unknown event
31
31
  * names are kept as written — they never fire, and a future daemon that
32
32
  * emits them starts firing without a config change. A `dir` is stored with
33
- * `~` expanded and trailing slashes trimmed, ready for path matching.
33
+ * `~` expanded to `home` and trailing slashes trimmed, ready for path
34
+ * matching.
34
35
  */
35
- export function collectHooks(raw: unknown): HooksConfig {
36
+ export function collectHooks(raw: unknown, home: string = resolveHomeDir()): HooksConfig {
36
37
  if (!isRecord(raw)) {
37
38
  return {};
38
39
  }
@@ -55,7 +56,7 @@ export function collectHooks(raw: unknown): HooksConfig {
55
56
 
56
57
  entries.push({
57
58
  command: parsed.data.command,
58
- ...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir) }),
59
+ ...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir, home) }),
59
60
  ...(parsed.data.timeout === undefined ? {} : { timeout: parsed.data.timeout }),
60
61
  });
61
62
  }
@@ -68,8 +69,8 @@ export function collectHooks(raw: unknown): HooksConfig {
68
69
  return hooks;
69
70
  }
70
71
 
71
- function normalizeHookDir(dir: string): string {
72
- const expanded = dir === '~' ? homedir() : dir.replace(/^~\//u, `${homedir()}/`);
72
+ function normalizeHookDir(dir: string, home: string): string {
73
+ const expanded = dir === '~' ? home : dir.replace(/^~\//u, `${home}/`);
73
74
  const trimmed = expanded.replace(/\/+$/u, '');
74
75
 
75
76
  return trimmed === '' ? '/' : trimmed;
@@ -176,13 +176,18 @@ const CONFIG_SCHEMA = z.object({
176
176
  * defaults out. A file that exists but cannot be read or parsed loads as
177
177
  * unusable: every default but the targets, which it leaves empty with the
178
178
  * problem as the one target error, so nothing runs until the file is fixed.
179
- * Never throws.
179
+ * `home` is the directory a `~` in the file expands to, and `state` is the
180
+ * state directory it creates. Never throws.
180
181
  */
181
- export function loadConfig(file: string = configFile): Config {
182
+ export function loadConfig(
183
+ file: string = configFile,
184
+ home: string = resolveHomeDir(),
185
+ state: string = stateDir,
186
+ ): Config {
182
187
  // Every atc process writes under the state directory; a failure here
183
188
  // comes back from the first write into it.
184
189
  try {
185
- mkdirSync(stateDir, { recursive: true });
190
+ mkdirSync(state, { recursive: true });
186
191
  } catch {}
187
192
 
188
193
  let text: string;
@@ -213,7 +218,7 @@ export function loadConfig(file: string = configFile): Config {
213
218
  return buildUnusableConfig('config_malformed', file, 'the file is not valid JSON');
214
219
  }
215
220
 
216
- return parseConfig(raw, file);
221
+ return parseConfig(raw, file, home);
217
222
  }
218
223
 
219
224
  /**
@@ -292,10 +297,15 @@ export function renderDefaultConfig(): string {
292
297
  * Parses a user-written config.json's already-decoded JSON value into a
293
298
  * Config, applying every default a malformed or absent field falls back to.
294
299
  * A root that is not an object leaves the config unusable, with no targets,
295
- * and `file` is the path its error holds. Total: no shape of `raw` throws,
296
- * so a hand-edited config never stops atc starting.
300
+ * and `file` is the path its error holds. `home` is the directory a `~` in
301
+ * a directory root or a hook's `dir` expands to. Total: no shape of `raw`
302
+ * throws, so a hand-edited config never stops atc starting.
297
303
  */
298
- export function parseConfig(raw: unknown, file: string = configFile): Config {
304
+ export function parseConfig(
305
+ raw: unknown,
306
+ file: string = configFile,
307
+ home: string = resolveHomeDir(),
308
+ ): Config {
299
309
  if (!isRecord(raw) || Array.isArray(raw)) {
300
310
  return buildUnusableConfig(
301
311
  'config_malformed',
@@ -320,7 +330,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
320
330
  return buildUnusableConfig('config_malformed', file, formatMixedAgentKeys(present));
321
331
  }
322
332
 
323
- const dirs = { roots: collectDirRoots(parsed.data.dirs) };
333
+ const dirs = { roots: collectDirRoots(parsed.data.dirs, home) };
324
334
  const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
325
335
  const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
326
336
 
@@ -340,7 +350,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
340
350
  authProfiles.profiles,
341
351
  );
342
352
 
343
- const hooks = collectHooks(parsed.data.hooks);
353
+ const hooks = collectHooks(parsed.data.hooks, home);
344
354
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
345
355
  const principals = collectPrincipals(parsed.data.principals);
346
356
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The time and the timers a module reads, so a test can step them. `now`
3
+ * returns milliseconds since the epoch; `schedule` runs the callback once
4
+ * after the delay and returns a function that cancels it, which does
5
+ * nothing once the callback has run.
6
+ */
7
+ export interface Clock {
8
+ readonly now: () => number;
9
+ readonly schedule: (callback: () => void, delayMs: number) => () => void;
10
+ }
11
+
12
+ /**
13
+ * The wall clock and the runtime's own timers.
14
+ */
15
+ export const systemClock: Clock = {
16
+ now: () => Date.now(),
17
+ schedule: (callback, delayMs) => {
18
+ const timer = setTimeout(callback, delayMs);
19
+
20
+ return () => {
21
+ clearTimeout(timer);
22
+ };
23
+ },
24
+ };
@@ -89,7 +89,7 @@ export async function collectGitHubRepos(
89
89
 
90
90
  const timeoutMs = request.timeoutMs ?? GH_TIMEOUT_MS;
91
91
 
92
- const listed = await runGH(bin, timeoutMs, [
92
+ const listed = await runGH(bin, AbortSignal.timeout(timeoutMs), [
93
93
  'repo',
94
94
  'list',
95
95
  ...(request.owner === null ? [] : [request.owner]),
@@ -5,7 +5,7 @@ import { runGH } from './run-gh';
5
5
  * so, else `https`, which a gh that fails or takes too long also gives.
6
6
  */
7
7
  export async function readGitProtocol(bin: string, timeoutMs: number): Promise<'https' | 'ssh'> {
8
- const run = await runGH(bin, timeoutMs, ['config', 'get', 'git_protocol']);
8
+ const run = await runGH(bin, AbortSignal.timeout(timeoutMs), ['config', 'get', 'git_protocol']);
9
9
 
10
10
  return run.exitCode === 0 && run.stdout.trim() === 'ssh' ? 'ssh' : 'https';
11
11
  }
@@ -6,14 +6,14 @@ export interface GHRun {
6
6
  }
7
7
 
8
8
  /**
9
- * Runs gh with arguments and a time limit, killing its whole process group
10
- * once the limit passes, so a wrapper or extension leaves no process
9
+ * Runs gh with arguments until it exits or the signal aborts, killing its
10
+ * whole process group on abort, so a wrapper or extension leaves no process
11
11
  * behind. gh reads its own config and the host's environment for its
12
12
  * token, and is kept from prompting, colouring, or checking for updates.
13
13
  */
14
14
  export async function runGH(
15
15
  bin: string,
16
- timeoutMs: number,
16
+ signal: Readonly<AbortSignal>,
17
17
  args: readonly string[],
18
18
  ): Promise<GHRun> {
19
19
  const proc = Bun.spawn([bin, ...args], {
@@ -40,15 +40,21 @@ export async function runGH(
40
40
  proc.exited,
41
41
  ]);
42
42
 
43
- const limit = Promise.withResolvers<null>();
43
+ const aborted = Promise.withResolvers<null>();
44
44
 
45
- const timer = setTimeout(() => {
46
- limit.resolve(null);
47
- }, timeoutMs);
45
+ const onAbort = () => {
46
+ aborted.resolve(null);
47
+ };
48
48
 
49
- const settled = await Promise.race([finished, limit.promise]);
49
+ if (signal.aborted) {
50
+ onAbort();
51
+ } else {
52
+ signal.addEventListener('abort', onAbort, { once: true });
53
+ }
54
+
55
+ const settled = await Promise.race([finished, aborted.promise]);
50
56
 
51
- clearTimeout(timer);
57
+ signal.removeEventListener('abort', onAbort);
52
58
 
53
59
  if (settled === null) {
54
60
  try {
@@ -2,7 +2,14 @@ import { Database } from 'bun:sqlite';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
5
- import type { Expression, ExpressionBuilder, SqlBool, Transaction } from 'kysely';
5
+ import type {
6
+ CompiledQuery,
7
+ Expression,
8
+ ExpressionBuilder,
9
+ LogEvent,
10
+ SqlBool,
11
+ Transaction,
12
+ } from 'kysely';
6
13
  import type { AdapterEvent } from '../protocol/adapter-event';
7
14
  import { DaemonError } from '../protocol/daemon-error';
8
15
  import type { HookEvent } from '../protocol/hook-event';
@@ -129,6 +136,9 @@ export class StateStore {
129
136
  // migration that created session ownership.
130
137
  readonly daemonID: DaemonID;
131
138
 
139
+ // The close under way or done, which every later close waits on.
140
+ private stopping: Promise<void> | null = null;
141
+
132
142
  private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
133
143
  this.sqlite = sqlite;
134
144
  this.db = db;
@@ -136,8 +146,13 @@ export class StateStore {
136
146
  }
137
147
 
138
148
  // Migrations run statements that cannot happen inside a constructor, so
139
- // opening a store is this factory instead of `new`.
140
- static async open(dbPath: string, legacyFleetPath?: string): Promise<StateStore> {
149
+ // opening a store is this factory instead of `new`. A caller that passes
150
+ // a query listener receives each statement the store runs once it ran.
151
+ static async open(
152
+ dbPath: string,
153
+ legacyFleetPath?: string,
154
+ onQuery?: (query: CompiledQuery) => void,
155
+ ): Promise<StateStore> {
141
156
  const sqlite = new Database(dbPath, { create: true });
142
157
 
143
158
  sqlite.run('PRAGMA journal_mode = WAL;');
@@ -149,6 +164,15 @@ export class StateStore {
149
164
  createIntrospector: (kysely) => new SqliteIntrospector(kysely),
150
165
  createQueryCompiler: () => new SqliteQueryCompiler(),
151
166
  },
167
+ ...(onQuery === undefined
168
+ ? {}
169
+ : {
170
+ log: (event: LogEvent) => {
171
+ if (event.level === 'query') {
172
+ onQuery(event.query);
173
+ }
174
+ },
175
+ }),
152
176
  });
153
177
 
154
178
  await runMigrations(db);
@@ -414,7 +438,13 @@ export class StateStore {
414
438
  });
415
439
  }
416
440
 
417
- async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
441
+ // Records a hook event in the trail, stamped with the given time in epoch
442
+ // milliseconds, or the wall clock's when absent.
443
+ async recordEvent(
444
+ e: HookEvent,
445
+ ev: Readonly<AdapterEvent> | null = null,
446
+ at: number = Date.now(),
447
+ ): Promise<void> {
418
448
  const rawMessage = e.payload['message'];
419
449
  const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
420
450
  const message = typeof rawMessage === 'string' ? rawMessage : null;
@@ -424,7 +454,7 @@ export class StateStore {
424
454
  await this.db
425
455
  .insertInto('events')
426
456
  .values({
427
- ts: new Date().toISOString(),
457
+ ts: new Date(at).toISOString(),
428
458
  atc_id: atcID,
429
459
  event: e.event,
430
460
  message,
@@ -1132,10 +1162,30 @@ export class StateStore {
1132
1162
  .execute();
1133
1163
  }
1134
1164
 
1165
+ // A second close, or a disposal after one, waits on the first and closes
1166
+ // nothing again; a close that fails is forgotten, so the next one retries.
1135
1167
  async stop(): Promise<void> {
1136
- await this.db.destroy();
1168
+ this.stopping ??= (async () => {
1169
+ await this.db.destroy();
1170
+
1171
+ this.sqlite.close();
1172
+ })();
1173
+
1174
+ const current = this.stopping;
1175
+
1176
+ try {
1177
+ await current;
1178
+ } catch (error) {
1179
+ if (this.stopping === current) {
1180
+ this.stopping = null;
1181
+ }
1182
+
1183
+ throw error;
1184
+ }
1185
+ }
1137
1186
 
1138
- this.sqlite.close();
1187
+ [Symbol.asyncDispose](): Promise<void> {
1188
+ return this.stop();
1139
1189
  }
1140
1190
 
1141
1191
  private async adoptLegacyFleet(legacyFleetPath: string): Promise<void> {
package/src/tap.ts CHANGED
@@ -19,6 +19,24 @@ interface InboxMessage {
19
19
  readonly sentAt: number;
20
20
  }
21
21
 
22
+ // Where the tap prints and how it exits: stdout, stderr, and the process by
23
+ // default.
24
+ interface TapIO {
25
+ readonly writeStdout: (text: string) => Promise<unknown>;
26
+ readonly printError: (line: string) => void;
27
+ readonly exit: (code: number) => void;
28
+ }
29
+
30
+ const PROCESS_IO: TapIO = {
31
+ writeStdout: (text) => Bun.write(Bun.stdout, text),
32
+ printError: (line) => {
33
+ console.error(line);
34
+ },
35
+ exit: (code) => {
36
+ process.exit(code);
37
+ },
38
+ };
39
+
22
40
  /**
23
41
  * Streams one session's inbox to stdout, one NDJSON line per message, and
24
42
  * acks each message once its line is written. It talks to the daemon that
@@ -26,15 +44,20 @@ interface InboxMessage {
26
44
  * serves, so a restart from here would take the whole fleet down. Exits 1
27
45
  * with a hint when no daemon listens or the session cannot be tapped, and
28
46
  * 0 once the daemon closes the connection or ends the subscription because
29
- * another tap replaced it or the session was removed.
47
+ * another tap replaced it or the session was removed. It dials the daemon
48
+ * at `socketPath`, the running daemon's socket by default.
30
49
  */
31
- export async function runTap(session: string): Promise<void> {
50
+ export async function runTap(
51
+ session: string,
52
+ socketPath: string = daemonSocketPath,
53
+ io: TapIO = PROCESS_IO,
54
+ ): Promise<void> {
32
55
  const bridge = process.env['ATC_SOCKET'];
33
56
 
34
57
  // Inside a remote host the daemon's own socket is out of reach, and the
35
58
  // session bridge serves the tap instead.
36
59
  if (process.env['ATC_BRIDGE'] === '1' && bridge !== undefined && bridge !== '') {
37
- await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '');
60
+ await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '', io);
38
61
 
39
62
  return;
40
63
  }
@@ -42,10 +65,12 @@ export async function runTap(session: string): Promise<void> {
42
65
  let client: DaemonClient;
43
66
 
44
67
  try {
45
- client = await DaemonClient.open(daemonSocketPath);
68
+ client = await DaemonClient.open(socketPath);
46
69
  } catch {
47
- console.error(`atc tap: no daemon at ${daemonSocketPath} — start atc first`);
48
- process.exit(1);
70
+ io.printError(`atc tap: no daemon at ${socketPath} — start atc first`);
71
+ io.exit(1);
72
+
73
+ return;
49
74
  }
50
75
 
51
76
  const closed = Promise.withResolvers<void>();
@@ -83,7 +108,7 @@ export async function runTap(session: string): Promise<void> {
83
108
 
84
109
  written = (async () => {
85
110
  await previous;
86
- await ackInboxMessage(client, session, msg);
111
+ await ackInboxMessage(client, session, msg, io);
87
112
  })();
88
113
  };
89
114
 
@@ -91,15 +116,18 @@ export async function runTap(session: string): Promise<void> {
91
116
  await client.sendHello(getBuild());
92
117
  await client.sendRequest('session.tap', { session });
93
118
  } catch (error) {
94
- console.error(`atc tap: ${formatError(error)}`);
119
+ io.printError(`atc tap: ${formatError(error)}`);
95
120
  client.stop();
96
- process.exit(1);
121
+ io.exit(1);
122
+
123
+ return;
97
124
  }
98
125
 
99
126
  await closed.promise;
100
127
  await written;
101
128
 
102
- process.exit(0);
129
+ client.stop();
130
+ io.exit(0);
103
131
  }
104
132
 
105
133
  interface RequestSender {
@@ -110,19 +138,22 @@ async function ackInboxMessage(
110
138
  client: RequestSender,
111
139
  session: string,
112
140
  msg: InboxMessage,
141
+ io: TapIO,
113
142
  ): Promise<void> {
114
143
  const line = `${JSON.stringify({ id: msg.message, from: msg.from, text: msg.text, sentAt: msg.sentAt })}\n`;
115
144
 
116
145
  try {
117
- await Bun.write(Bun.stdout, line);
146
+ await io.writeStdout(line);
118
147
  } catch {
119
- process.exit(1);
148
+ io.exit(1);
149
+
150
+ return;
120
151
  }
121
152
 
122
153
  try {
123
154
  await client.sendRequest('message.ack', { session, message: msg.message });
124
155
  } catch (error) {
125
- console.error(`atc tap: ${formatError(error)}`);
156
+ io.printError(`atc tap: ${formatError(error)}`);
126
157
  }
127
158
  }
128
159
 
@@ -0,0 +1,25 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+
3
+ /**
4
+ * An agent adapter for daemon tests: id `claude`, a spawn that runs
5
+ * `sleep 30` so the session stays alive with no agent CLI behind it, every
6
+ * hook read as a heartbeat, no name, no resume command, no headless runner,
7
+ * no screen detector, and no inbox messages. Every optional member is
8
+ * absent, so the daemon takes the stand-in path for each one, and
9
+ * `agents.list` reports the adapter as not installed. An override replaces
10
+ * the member it names.
11
+ */
12
+ export function buildMockAgentAdapter(overrides: Partial<AgentAdapter> = {}): AgentAdapter {
13
+ return {
14
+ id: 'claude',
15
+ headlessRunner: null,
16
+ screenDetector: null,
17
+ takesMessages: false,
18
+ planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
19
+ normalizeHook: () => ({ kind: 'heartbeat' }),
20
+ loadName: () => Promise.resolve(null),
21
+ canResume: () => true,
22
+ buildResumeCommand: () => null,
23
+ ...overrides,
24
+ };
25
+ }
@@ -0,0 +1,32 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { AgentEntry } from '../shared/collect-agents';
3
+ import { mergeDeep } from './merge-deep';
4
+ import type { MockOverrides } from './mock-overrides';
5
+
6
+ /**
7
+ * The fields the defaults set; every other field is absent until an
8
+ * override gives it whole.
9
+ */
10
+ type DefaultedKey = 'id' | 'kind' | 'label' | 'mark' | 'bin' | 'args' | 'env';
11
+
12
+ /**
13
+ * A registry entry for a plain Claude agent: kind `claude`, no arguments,
14
+ * no environment, and none of the gateway fields. The id, label, mark, and
15
+ * binary are arbitrary. Overrides merge into fresh defaults at every depth.
16
+ */
17
+ export function buildMockAgentEntry(
18
+ overrides: MockOverrides<AgentEntry, DefaultedKey> = {},
19
+ ): AgentEntry {
20
+ return mergeDeep<AgentEntry, DefaultedKey>(
21
+ {
22
+ id: faker.string.alpha({ length: 8, casing: 'lower' }),
23
+ kind: 'claude',
24
+ label: faker.company.name(),
25
+ mark: faker.string.alpha({ length: 1, casing: 'lower' }),
26
+ bin: faker.system.filePath(),
27
+ args: [],
28
+ env: {},
29
+ },
30
+ overrides,
31
+ );
32
+ }
@@ -0,0 +1,28 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { AuthBinding } from '../daemon/build-auth-binding';
3
+ import { toAgentID } from '../shared/to-agent-id';
4
+ import { mergeDeep } from './merge-deep';
5
+ import type { MockOverrides } from './mock-overrides';
6
+
7
+ /**
8
+ * A runtime auth binding for agent `glm` at an arbitrary endpoint, reaching
9
+ * no profile and needing no secret, with no placeholder or profile
10
+ * variables and an arbitrary hash. Overrides merge into fresh defaults at
11
+ * every depth, and a list replaces its default whole.
12
+ */
13
+ export function buildMockAuthBinding(
14
+ overrides: MockOverrides<AuthBinding, keyof AuthBinding> = {},
15
+ ): AuthBinding {
16
+ return mergeDeep<AuthBinding>(
17
+ {
18
+ agent: toAgentID('glm'),
19
+ baseURL: faker.internet.url(),
20
+ profiles: [],
21
+ secrets: [],
22
+ placeholderEnv: {},
23
+ profileEnv: {},
24
+ hash: faker.string.hexadecimal({ length: 64, casing: 'lower', prefix: '' }),
25
+ },
26
+ overrides,
27
+ );
28
+ }
@@ -0,0 +1,62 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { AuthProfile } from '../shared/collect-auth-profiles';
3
+ import { mergeDeep } from './merge-deep';
4
+ import type { MockOverrides } from './mock-overrides';
5
+
6
+ /**
7
+ * The auth profile kinds that inject a header for a broker host.
8
+ */
9
+ type HeaderAuthProfile = Extract<AuthProfile, { readonly host: string }>;
10
+
11
+ /**
12
+ * The auth profile kind that signs in to GitHub.
13
+ */
14
+ type GitHubAuthProfile = Extract<AuthProfile, { readonly kind: 'github' }>;
15
+
16
+ type HeaderOverrides = MockOverrides<HeaderAuthProfile, keyof HeaderAuthProfile>;
17
+
18
+ type GitHubOverrides = MockOverrides<GitHubAuthProfile, keyof GitHubAuthProfile> & {
19
+ readonly kind: 'github';
20
+ };
21
+
22
+ /**
23
+ * An auth profile of kind `custom` that sends its secret as a bearer token
24
+ * in a header, or, for an override of kind `github`, a GitHub profile.
25
+ * Either has no environment and no dependencies, and an arbitrary name and
26
+ * secret; a custom profile's host and header name are arbitrary too.
27
+ * Overrides merge into fresh defaults at every depth.
28
+ */
29
+ export function buildMockAuthProfile(overrides?: HeaderOverrides): HeaderAuthProfile;
30
+ export function buildMockAuthProfile(overrides: GitHubOverrides): GitHubAuthProfile;
31
+
32
+ export function buildMockAuthProfile(
33
+ overrides: HeaderOverrides | GitHubOverrides = {},
34
+ ): AuthProfile {
35
+ const name = faker.string.alpha({ length: 8, casing: 'lower' });
36
+ const secret = faker.string.alpha({ length: 8, casing: 'lower' });
37
+
38
+ return isGitHubOverrides(overrides)
39
+ ? mergeDeep<GitHubAuthProfile>(
40
+ { name, secret, kind: 'github', env: {}, dependencies: [] },
41
+ overrides,
42
+ )
43
+ : mergeDeep<HeaderAuthProfile>(
44
+ {
45
+ name,
46
+ secret,
47
+ kind: 'custom',
48
+ host: faker.internet.domainName(),
49
+ header: `x-${faker.string.alpha({ length: 6, casing: 'lower' })}-key`,
50
+ scheme: 'bearer',
51
+ env: {},
52
+ dependencies: [],
53
+ },
54
+ overrides,
55
+ );
56
+ }
57
+
58
+ function isGitHubOverrides(
59
+ overrides: HeaderOverrides | GitHubOverrides,
60
+ ): overrides is GitHubOverrides {
61
+ return overrides.kind === 'github';
62
+ }