@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,291 @@
1
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { spawn } from 'bun-pty';
4
+ import type { IDisposable, IPty } from 'bun-pty';
5
+ import { buildStubTUIClaude } from './build-stub-tui-claude';
6
+ import { buildStubTUIGrok } from './build-stub-tui-grok';
7
+ import { createStubBin } from './create-stub-bin';
8
+ import { mergeDeep } from './merge-deep';
9
+ import { registerTestCleanup } from './register-test-cleanup';
10
+ import { setupTempDir } from './setup-temp-dir';
11
+ import { waitFor } from './wait-for';
12
+
13
+ /**
14
+ * One home with the real client running in it under a pseudo-terminal,
15
+ * and the calls that drive and read it.
16
+ */
17
+ export type TUIHarness = ReturnType<typeof startTUIHarness>;
18
+
19
+ const cliPath = join(import.meta.dir, '..', 'cli.ts');
20
+
21
+ interface TUIHarnessOptions {
22
+ // How long a wait gives the client to draw its first byte; 9 seconds
23
+ // unless set.
24
+ readonly bootMs?: number;
25
+ }
26
+
27
+ /**
28
+ * A fresh home for one run of the real `atc` client in a pseudo-terminal,
29
+ * holding stand-in `claude` and `grok` binaries, which take scenario files
30
+ * dropped into the home, and a config that points at them. `boot` starts
31
+ * the client in the home, which starts its own daemon there; a later `boot`
32
+ * starts a new client on the same home and stops capturing the old one.
33
+ * `read` returns every byte the client drew since the last `reset`, and
34
+ * `waitFor` polls that capture for a needle. The client appends what it
35
+ * decides without drawing to a log: `markClientLog` returns a cursor into
36
+ * it, and `waitForClientLog` polls for a line written after that cursor.
37
+ * `writeConfig` writes the home's config: the stand-in binaries and the git
38
+ * transports the fixture repositories need, with the fields given laid over
39
+ * them. `env` is the environment the client runs with, so a daemon started
40
+ * with it serves the client. A wait made before the client draws anything
41
+ * gets `bootMs` for that first byte. Disposal stops the client and the
42
+ * daemon in the home and removes it. That disposal runs once the current
43
+ * test finishes, so it must run inside a test; disposing sooner runs it
44
+ * then, and a second disposal does nothing.
45
+ */
46
+ export function startTUIHarness(options: TUIHarnessOptions = {}) {
47
+ const tmp = setupTempDir('atc-tui-');
48
+
49
+ // Registered after the home, so it releases first: the client and its
50
+ // daemon stop before their home is removed.
51
+ const owned = new AsyncDisposableStack();
52
+
53
+ const dispose = registerTestCleanup(() => owned.disposeAsync());
54
+
55
+ owned.use(tmp);
56
+
57
+ // The client boots with this home as its cwd and lists it first in the
58
+ // picker, so the path is resolved the way the client reports it.
59
+ const home = realpathSync(tmp.dir);
60
+ const configPath = join(home, '.config', 'atc', 'config.json');
61
+ const clientLogPath = join(home, 'client.log');
62
+ const fakeClaude = createStubBin(home, 'fake-claude', buildStubTUIClaude());
63
+ const fakeGrok = createStubBin(home, 'fake-grok', buildStubTUIGrok());
64
+
65
+ const bootConfig: Readonly<Record<string, unknown>> = {
66
+ claudeBin: fakeClaude,
67
+ claudeArgs: [],
68
+ grokBin: fakeGrok,
69
+ grokArgs: [],
70
+
71
+ // No codex binary is written to the home, so the agent picker sees
72
+ // Codex as uninstalled whatever the host machine has.
73
+ codexBin: join(home, 'fake-codex'),
74
+ codexArgs: [],
75
+ gateways: [],
76
+
77
+ // The fixture repositories are local paths and loopback HTTP, which the
78
+ // default transports refuse.
79
+ workspaces: { gitTransports: ['https', 'ssh', 'http', 'file'] },
80
+ };
81
+
82
+ const writeConfig = (fields: Readonly<Record<string, unknown>> = {}) => {
83
+ mkdirSync(join(home, '.config', 'atc'), { recursive: true });
84
+ writeFileSync(configPath, JSON.stringify(mergeDeep(bootConfig, fields)));
85
+ };
86
+
87
+ writeConfig();
88
+
89
+ // A test that drops an executable into the home's bin directory puts it
90
+ // on the PATH of the client and of the daemons it starts.
91
+ const env = {
92
+ ...process.env,
93
+ HOME: home,
94
+ XDG_RUNTIME_DIR: home,
95
+ PATH: `${join(home, 'bin')}:/usr/sbin:/usr/bin:/bin`,
96
+ ATC_CLIENT_LOG: clientLogPath,
97
+ };
98
+
99
+ let pty: IPty | null = null;
100
+ let capture: IDisposable | null = null;
101
+ let exited: Promise<number> | null = null;
102
+ let out = '';
103
+
104
+ // When the latest client boot wrote its first byte. The client draws
105
+ // nothing until its daemon handshake completes, so that byte is the
106
+ // readiness signal: waits before it get the boot budget, waits after it
107
+ // the interaction budget.
108
+ let firstOutputAt: number | null = null;
109
+
110
+ owned.defer(() => stopClientAndDaemon(home, pty, firstOutputAt === null));
111
+
112
+ return {
113
+ home,
114
+ configPath,
115
+ env,
116
+ writeConfig,
117
+
118
+ boot(): IPty {
119
+ capture?.dispose();
120
+
121
+ const booted = spawn(process.execPath, [cliPath], {
122
+ name: 'xterm-256color',
123
+ cols: 110,
124
+ rows: 30,
125
+
126
+ // The picker lists the client's own directory first, so a spawn
127
+ // that takes the first entry lands in the home.
128
+ cwd: home,
129
+ env,
130
+ });
131
+
132
+ const exit = Promise.withResolvers<number>();
133
+
134
+ booted.onExit((event) => {
135
+ exit.resolve(event.exitCode);
136
+ });
137
+
138
+ firstOutputAt = null;
139
+ pty = booted;
140
+ exited = exit.promise;
141
+
142
+ capture = booted.onData((data) => {
143
+ firstOutputAt ??= Date.now();
144
+ out += data;
145
+ });
146
+
147
+ return booted;
148
+ },
149
+
150
+ write(data: string) {
151
+ if (pty === null) {
152
+ throw new Error('write before boot');
153
+ }
154
+
155
+ pty.write(data);
156
+ },
157
+
158
+ read(): string {
159
+ return out;
160
+ },
161
+
162
+ reset() {
163
+ out = '';
164
+ },
165
+
166
+ async waitFor(needle: string, ms = 4000): Promise<void> {
167
+ const start = Date.now();
168
+
169
+ // The client gives its daemon 8 seconds to answer before it draws an
170
+ // error, so the boot phase waits that long plus 1 second for the
171
+ // client's own cold start, which measures under 1.1 seconds on a
172
+ // runner loaded at four times its cores. Every wait after the first
173
+ // byte keeps its own deadline.
174
+ const bootMs = options.bootMs ?? 8000 + 1000;
175
+
176
+ const firstAt = await waitFor(
177
+ () => {
178
+ if (firstOutputAt === null) {
179
+ throw new Error(
180
+ `timed out waiting for ${JSON.stringify(needle)}; the client wrote nothing in ${bootMs}ms of boot`,
181
+ );
182
+ }
183
+
184
+ return firstOutputAt;
185
+ },
186
+ { timeoutMs: bootMs, intervalMs: 50 },
187
+ );
188
+
189
+ await waitFor(
190
+ () => {
191
+ if (!out.includes(needle)) {
192
+ throw new Error(
193
+ `timed out waiting for ${JSON.stringify(needle)}; tail: ${JSON.stringify(out.slice(-400))}`,
194
+ );
195
+ }
196
+ },
197
+ { timeoutMs: Math.max(0, Math.max(start, firstAt) + ms - Date.now()), intervalMs: 50 },
198
+ );
199
+ },
200
+
201
+ waitForExit(): Promise<number> {
202
+ if (exited === null) {
203
+ throw new Error('wait for exit before boot');
204
+ }
205
+
206
+ return exited;
207
+ },
208
+
209
+ markClientLog(): number {
210
+ return readClientLog(clientLogPath).length;
211
+ },
212
+
213
+ async waitForClientLog(line: string, mark: number, ms = 5000): Promise<void> {
214
+ await waitFor(
215
+ () => {
216
+ const written = readClientLog(clientLogPath).slice(mark);
217
+
218
+ if (!written.includes(line)) {
219
+ throw new Error(
220
+ `the client log never held ${JSON.stringify(line)} after line ${mark}; it holds ${JSON.stringify(written)} there`,
221
+ );
222
+ }
223
+ },
224
+ { timeoutMs: ms },
225
+ );
226
+ },
227
+
228
+ [Symbol.asyncDispose]: dispose,
229
+ };
230
+ }
231
+
232
+ // The client started a daemon inside the home; its pid file is how the
233
+ // harness finds and stops it. The daemon writes that file once its modules
234
+ // load, so a client killed before its first frame can leave a daemon that
235
+ // has not written it yet.
236
+ async function stopClientAndDaemon(home: string, pty: IPty | null, beforeFirstFrame: boolean) {
237
+ pty?.kill();
238
+
239
+ const pid = await waitFor(
240
+ () => {
241
+ const read = Number(readFileSync(join(home, 'atc-daemon.pid'), 'utf8'));
242
+
243
+ if (!Number.isInteger(read) || read <= 1) {
244
+ throw new Error(`the daemon pid file holds no pid: ${read}`);
245
+ }
246
+
247
+ return read;
248
+ },
249
+ { timeoutMs: pty !== null && beforeFirstFrame ? 5000 : 0, intervalMs: 50 },
250
+ ).catch(() => null);
251
+
252
+ if (pid === null) {
253
+ return;
254
+ }
255
+
256
+ try {
257
+ process.kill(pid, 'SIGTERM');
258
+ } catch {}
259
+
260
+ // The next test's cold boot must not share the CPU with this daemon's
261
+ // shutdown, and the daemon must not outlive its home.
262
+ await waitFor(
263
+ () => {
264
+ if (isRunning(pid)) {
265
+ throw new Error(`daemon ${pid} still runs`);
266
+ }
267
+ },
268
+ { timeoutMs: 3000 },
269
+ ).catch(() => {});
270
+ }
271
+
272
+ function isRunning(pid: number): boolean {
273
+ try {
274
+ process.kill(pid, 0);
275
+
276
+ return true;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ // The client log's lines, or none before the client writes the file.
283
+ function readClientLog(path: string): string[] {
284
+ try {
285
+ return readFileSync(path, 'utf8')
286
+ .split('\n')
287
+ .filter((line) => line !== '');
288
+ } catch {
289
+ return [];
290
+ }
291
+ }
@@ -1,21 +1,52 @@
1
+ import { OutboundQueue } from '../protocol/outbound-queue';
2
+ import { registerTestCleanup } from './register-test-cleanup';
1
3
  import { waitFor } from './wait-for';
2
4
 
3
5
  interface SocketLines {
6
+ // Every complete newline-terminated line the peer sent, in order.
4
7
  readonly lines: string[];
8
+
9
+ // Resolves once the connection has closed, from either end.
10
+ readonly closed: Promise<void>;
11
+
5
12
  readonly write: (data: string) => void;
6
13
  readonly waitForLine: (count?: number, timeoutMs?: number) => Promise<string[]>;
7
14
  readonly [Symbol.asyncDispose]: () => Promise<void>;
8
15
  }
9
16
 
17
+ interface SocketLinesOptions {
18
+ // How many unsent bytes the connection holds before it refuses a write.
19
+ readonly queueBytes?: number;
20
+
21
+ // The clock a line wait reads its deadline from; the wall clock when absent.
22
+ readonly now?: () => number;
23
+
24
+ // Waits out the interval between a line wait's polls; a real sleep when
25
+ // absent.
26
+ readonly wait?: (ms: number) => Promise<void>;
27
+ }
28
+
10
29
  /**
11
30
  * Connects to a unix socket and collects every complete newline-terminated
12
- * line it sends, buffering a partial line across reads. `waitForLine` polls
13
- * until the collected count reaches `count` and returns the lines, throwing
14
- * when `timeoutMs` passes first; disposal ends the connection.
31
+ * line it sends, buffering a partial line across reads. `write` sends the
32
+ * whole of what it is given, the rest going out as the socket drains,
33
+ * and throws instead when the unsent bytes already fill the queue.
34
+ * `waitForLine` polls until the collected count reaches `count` and returns
35
+ * the lines, throwing when `timeoutMs` passes first or the connection
36
+ * closes short of the count; a stub clock passed as `now` and `wait` steps
37
+ * that timeout without waiting it out. The connection ends once the current
38
+ * test finishes, so it must run inside a test; disposal ends it sooner, and
39
+ * a second end does nothing.
15
40
  */
16
- export async function subscribeToSocketLines(path: string): Promise<SocketLines> {
41
+ export async function subscribeToSocketLines(
42
+ path: string,
43
+ options: SocketLinesOptions = {},
44
+ ): Promise<SocketLines> {
17
45
  const lines: string[] = [];
46
+ const closed = Promise.withResolvers<void>();
47
+ let isClosed = false;
18
48
  let buffer = '';
49
+ let queue: OutboundQueue | null = null;
19
50
 
20
51
  const socket = await Bun.connect({
21
52
  unix: path,
@@ -29,30 +60,66 @@ export async function subscribeToSocketLines(path: string): Promise<SocketLines>
29
60
 
30
61
  lines.push(...parts.filter((part) => part.trim() !== ''));
31
62
  },
32
- close() {},
63
+ drain() {
64
+ queue?.drain();
65
+ },
66
+ close() {
67
+ isClosed = true;
68
+
69
+ closed.resolve();
70
+ },
33
71
  error() {},
34
72
  },
35
73
  });
36
74
 
75
+ const stop = registerTestCleanup(() => {
76
+ socket.end();
77
+ });
78
+
79
+ // The default leaves room for the largest payload a test sends in one
80
+ // piece.
81
+ queue = new OutboundQueue(socket, options.queueBytes ?? 8 * 1024 * 1024);
82
+
83
+ const sending = queue;
84
+
85
+ const requireLines = (count: number) => {
86
+ if (lines.length < count) {
87
+ throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
88
+ }
89
+
90
+ return lines;
91
+ };
92
+
37
93
  return {
38
94
  lines,
95
+ closed: closed.promise,
39
96
  write(data: string) {
40
- socket.write(data);
97
+ if (!sending.send(data)) {
98
+ throw new Error(
99
+ `the socket refused a ${data.length}-character write: ${sending.queuedBytes} bytes are still unsent`,
100
+ );
101
+ }
41
102
  },
42
- waitForLine(count = 1, timeoutMs = 5000) {
43
- return waitFor(
103
+ async waitForLine(count = 1, timeoutMs = 5000) {
104
+ // The poll ends early once the connection closes, since no more lines
105
+ // can arrive.
106
+ await waitFor(
44
107
  () => {
45
- if (lines.length < count) {
46
- throw new Error(`timed out waiting for ${count} lines; got ${JSON.stringify(lines)}`);
108
+ if (!isClosed) {
109
+ requireLines(count);
47
110
  }
48
-
49
- return lines;
50
111
  },
51
- { timeoutMs },
112
+ {
113
+ timeoutMs,
114
+ now: options.now ?? Date.now,
115
+ wait: options.wait ?? Bun.sleep,
116
+ },
52
117
  );
118
+
119
+ return requireLines(count);
53
120
  },
54
121
  [Symbol.asyncDispose]: () => {
55
- socket.end();
122
+ stop();
56
123
 
57
124
  return Promise.resolve();
58
125
  },
@@ -0,0 +1,29 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+
3
+ /**
4
+ * Sends a request and returns its answer as plain data, `{ ok }` or, when
5
+ * the daemon refuses it, `{ error: { code, message, data } }`, with every
6
+ * occurrence of `id` replaced by `<id>`, so the answers to the same request
7
+ * for two ids compare whole: a refusal for a session a client may not see
8
+ * and one for a session that never existed must read alike. A failure that
9
+ * is not a daemon refusal rejects as it is.
10
+ */
11
+ export async function trySendRequest(
12
+ send: () => Promise<Readonly<Record<string, unknown>>>,
13
+ id: string,
14
+ ): Promise<unknown> {
15
+ const answer = await send().then(
16
+ (ok) => ({ ok }),
17
+ (error: unknown) => ({ error: toRefusal(error) }),
18
+ );
19
+
20
+ return JSON.parse(JSON.stringify(answer).replaceAll(id, '<id>'));
21
+ }
22
+
23
+ function toRefusal(error: unknown): unknown {
24
+ if (!(error instanceof DaemonError)) {
25
+ throw error;
26
+ }
27
+
28
+ return { code: error.code, message: error.message, data: error.data ?? null };
29
+ }
@@ -0,0 +1,41 @@
1
+ import { expect } from 'bun:test';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import { waitFor } from './wait-for';
4
+
5
+ /**
6
+ * Waits until the collected events hold one that matches the shape, as
7
+ * `toMatchObject` matches, and resolves with the first such event. Every
8
+ * event collected so far counts, so a wait finds an event that arrived
9
+ * before it began, and every event stays as it arrived. Throws once the
10
+ * deadline passes, five seconds unless the options set another, listing
11
+ * the shape and the events it saw.
12
+ */
13
+ export function waitForEvent(
14
+ events: readonly EventMsg[],
15
+ shape: Readonly<Record<string, unknown>>,
16
+ options: Parameters<typeof waitFor>[1] = {},
17
+ ): Promise<EventMsg> {
18
+ return waitFor(() => {
19
+ const found = events.find((event) => isMatch(event, shape));
20
+
21
+ if (found === undefined) {
22
+ throw new Error(
23
+ `no event matches ${JSON.stringify(shape)}; got ${JSON.stringify(events.map((event) => event.ev))}`,
24
+ );
25
+ }
26
+
27
+ return found;
28
+ }, options);
29
+ }
30
+
31
+ // The match runs on a copy: Bun's toMatchObject writes each asymmetric
32
+ // matcher it compares over the field it matched in the received object.
33
+ function isMatch(event: EventMsg, shape: Readonly<Record<string, unknown>>): boolean {
34
+ try {
35
+ expect(structuredClone(event)).toMatchObject(shape);
36
+
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
@@ -8,6 +8,16 @@ interface WaitForOptions {
8
8
  * Milliseconds before the wait gives up.
9
9
  */
10
10
  readonly timeoutMs?: number;
11
+
12
+ /**
13
+ * The clock the deadline is read from.
14
+ */
15
+ readonly now?: () => number;
16
+
17
+ /**
18
+ * Waits out the interval between retries.
19
+ */
20
+ readonly wait?: (ms: number) => Promise<void>;
11
21
  }
12
22
 
13
23
  /**
@@ -22,17 +32,19 @@ export async function waitFor<T>(
22
32
  ): Promise<T> {
23
33
  const intervalMs = options.intervalMs ?? 20;
24
34
  const timeoutMs = options.timeoutMs ?? 5000;
25
- const deadline = Date.now() + timeoutMs;
35
+ const now = options.now ?? Date.now;
36
+ const wait = options.wait ?? Bun.sleep;
37
+ const deadline = now() + timeoutMs;
26
38
 
27
39
  for (;;) {
28
40
  try {
29
41
  return await attempt();
30
42
  } catch (error) {
31
- if (Date.now() >= deadline) {
43
+ if (now() >= deadline) {
32
44
  throw error;
33
45
  }
34
46
  }
35
47
 
36
- await Bun.sleep(intervalMs);
48
+ await wait(intervalMs);
37
49
  }
38
50
  }
@@ -3,6 +3,8 @@ import type { RestartFailedRow } from './parse-restart-result';
3
3
  import type { StoredRow } from './read-stored-rows';
4
4
  import { readStoredRows } from './read-stored-rows';
5
5
  import { isRecord } from './shared/report';
6
+ import { systemClock } from './shared/system-clock';
7
+ import type { Clock } from './shared/system-clock';
6
8
 
7
9
  interface FleetVerdict {
8
10
  // The rows the daemon stored, exited ones included.
@@ -30,14 +32,16 @@ const UNSIZED_READ_MS = 30_000;
30
32
  * at the deadline failed to revive. The deadline counts from the call, and
31
33
  * every request to the daemon is held to it: the daemon must answer the first
32
34
  * list before it, and when it overtakes a later list, the verdict comes from
33
- * the last list the daemon answered.
35
+ * the last list the daemon answered. The deadline, the waits between lists,
36
+ * and every request's time limit run on `clock`, the wall clock unless given.
34
37
  */
35
38
  export async function verifyRestoredFleet(
36
39
  client: Pick<DaemonClient, 'sendRequest'>,
37
40
  timeoutSeconds: number | null,
38
41
  snapshot: readonly StoredRow[] | null,
42
+ clock: Clock = systemClock,
39
43
  ): Promise<FleetVerdict> {
40
- const startedAt = Date.now();
44
+ const startedAt = clock.now();
41
45
 
42
46
  // Without a snapshot the rows come from the new daemon, and its count is
43
47
  // not known until they are read, so that read gets the fixed allowance.
@@ -46,19 +50,22 @@ export async function verifyRestoredFleet(
46
50
  ? await sendBounded(
47
51
  () => readStoredRows(client),
48
52
  startedAt + (timeoutSeconds === null ? UNSIZED_READ_MS : timeoutSeconds * 1000),
53
+ clock,
49
54
  )
50
55
  : null;
51
56
 
52
57
  const stored = snapshot ?? read ?? [];
53
58
  const deadline = startedAt + pickDeadlineMs(stored, timeoutSeconds);
54
59
 
55
- const restored = await tryRestore(client, deadline);
56
- let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline);
60
+ const restored = await tryRestore(client, deadline, clock);
61
+ let found = await sendBounded(() => collectFailedRows(client, stored, restored), deadline, clock);
57
62
 
58
- while (found.pending && Date.now() < deadline) {
59
- await Bun.sleep(Math.min(250, deadline - Date.now()));
63
+ while (found.pending && clock.now() < deadline) {
64
+ await new Promise<void>((resolve) => {
65
+ clock.schedule(resolve, Math.min(250, deadline - clock.now()));
66
+ });
60
67
 
61
- const polled = await tryCollectFailedRows(client, stored, restored, deadline);
68
+ const polled = await tryCollectFailedRows(client, stored, restored, deadline, clock);
62
69
 
63
70
  if (polled === null) {
64
71
  break;
@@ -74,22 +81,22 @@ export async function verifyRestoredFleet(
74
81
  * Runs a request and rejects once the deadline passes, so a daemon that
75
82
  * stops answering cannot hold the restart, and its lock, open.
76
83
  */
77
- async function sendBounded<T>(send: () => Promise<T>, deadline: number): Promise<T> {
78
- let timer: ReturnType<typeof setTimeout> | undefined;
84
+ async function sendBounded<T>(send: () => Promise<T>, deadline: number, clock: Clock): Promise<T> {
85
+ let stopTimer: (() => void) | undefined;
79
86
 
80
87
  const expired = new Promise<never>((_resolve, reject) => {
81
- timer = setTimeout(
88
+ stopTimer = clock.schedule(
82
89
  () => {
83
90
  reject(new Error('the new daemon stopped answering before the restore deadline'));
84
91
  },
85
- Math.max(0, deadline - Date.now()),
92
+ Math.max(0, deadline - clock.now()),
86
93
  );
87
94
  });
88
95
 
89
96
  try {
90
97
  return await Promise.race([send(), expired]);
91
98
  } finally {
92
- clearTimeout(timer);
99
+ stopTimer?.();
93
100
  }
94
101
  }
95
102
 
@@ -107,9 +114,14 @@ function pickDeadlineMs(stored: readonly StoredRow[], timeoutSeconds: number | n
107
114
  async function tryRestore(
108
115
  client: Pick<DaemonClient, 'sendRequest'>,
109
116
  deadline: number,
117
+ clock: Clock,
110
118
  ): Promise<boolean> {
111
119
  try {
112
- await sendBounded(() => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }), deadline);
120
+ await sendBounded(
121
+ () => client.sendRequest('fleet.restore', { cols: 80, rows: 24 }),
122
+ deadline,
123
+ clock,
124
+ );
113
125
 
114
126
  return true;
115
127
  } catch {
@@ -126,22 +138,23 @@ async function tryCollectFailedRows(
126
138
  stored: readonly StoredRow[],
127
139
  restored: boolean,
128
140
  deadline: number,
141
+ clock: Clock,
129
142
  ): Promise<FailedRows | null> {
130
- let timer: ReturnType<typeof setTimeout> | undefined;
143
+ let stopTimer: (() => void) | undefined;
131
144
 
132
145
  const expired = new Promise<null>((resolve) => {
133
- timer = setTimeout(
146
+ stopTimer = clock.schedule(
134
147
  () => {
135
148
  resolve(null);
136
149
  },
137
- Math.max(0, deadline - Date.now()),
150
+ Math.max(0, deadline - clock.now()),
138
151
  );
139
152
  });
140
153
 
141
154
  try {
142
155
  return await Promise.race([collectFailedRows(client, stored, restored), expired]);
143
156
  } finally {
144
- clearTimeout(timer);
157
+ stopTimer?.();
145
158
  }
146
159
  }
147
160
 
@@ -21,6 +21,9 @@ interface AccessRequest {
21
21
 
22
22
  // The transports the URL may use and git may fetch over.
23
23
  readonly transports: readonly string[];
24
+
25
+ // Called with the pid of the listing's git as it starts.
26
+ readonly onSpawn?: ((pid: number) => void) | undefined;
24
27
  }
25
28
 
26
29
  interface RepositoryAccess {
@@ -77,6 +80,7 @@ export async function checkRepositoryAccess(
77
80
  request.credential,
78
81
  request.transports,
79
82
  request.timeoutMs,
83
+ request.onSpawn,
80
84
  );
81
85
 
82
86
  if (!listing.ok) {