@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,96 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
3
+
4
+ interface StubBrokeredAgentAdapterConfig {
5
+ readonly id: string;
6
+
7
+ // Whether the agent starts only behind the broker; false lets it start
8
+ // on a target that reaches no broker.
9
+ readonly brokerRequired: boolean;
10
+
11
+ // Whether the agent takes its credential from the broker at the moment
12
+ // of the call, as a config change can turn it on or off for an agent
13
+ // whose sessions already exist.
14
+ readonly isSelected: () => boolean;
15
+ }
16
+
17
+ /**
18
+ * An agent adapter for daemon tests that takes the credential of a GLM
19
+ * gateway from impd's broker: the auth profile `glm`, a custom bearer
20
+ * secret `glm` for api.z.ai, behind the gateway at
21
+ * https://api.z.ai/api/anthropic with the placeholder
22
+ * `ANTHROPIC_AUTH_TOKEN`. A guest spawn under a binding runs a shell that
23
+ * prints `revision <n>` for the binding's revision and then sleeps, with
24
+ * the binding's variables and `CLAUDE_CONFIG_DIR` set to `claude-config`
25
+ * in the guest folder; a guest spawn without one, and a local spawn, sleep.
26
+ * Every other member is the plain agent adapter's.
27
+ */
28
+ export function buildStubBrokeredAgentAdapter(
29
+ config: StubBrokeredAgentAdapterConfig,
30
+ ): AgentAdapter {
31
+ return buildMockAgentAdapter({
32
+ id: config.id,
33
+ profile: {
34
+ label: 'GLM',
35
+ kind: 'claude',
36
+ bin: 'sh',
37
+ models: null,
38
+ spawnOptions: {
39
+ model: {
40
+ supported: false,
41
+ values: null,
42
+ examples: [],
43
+ default: null,
44
+ backendEffect: null,
45
+ note: null,
46
+ },
47
+ effort: {
48
+ supported: false,
49
+ values: null,
50
+ examples: [],
51
+ default: null,
52
+ backendEffect: null,
53
+ note: null,
54
+ },
55
+ },
56
+ },
57
+ planGuestSpawn: (_opts, guest) =>
58
+ guest.auth === undefined
59
+ ? { bin: 'sleep', args: ['30'], files: {} }
60
+ : {
61
+ bin: 'sh',
62
+ args: ['-c', `echo "revision ${String(guest.auth.revision)}"; exec sleep 30`],
63
+ files: {},
64
+ env: { ...guest.auth.env, CLAUDE_CONFIG_DIR: `${guest.dir}/claude-config` },
65
+ },
66
+ findAuthSelection: () =>
67
+ config.isSelected()
68
+ ? {
69
+ brokerRequired: config.brokerRequired,
70
+ gateway: {
71
+ id: 'glm',
72
+ baseURL: 'https://api.z.ai/api/anthropic',
73
+ auth: {
74
+ profiles: ['glm'],
75
+ placeholderEnv: { ANTHROPIC_AUTH_TOKEN: 'imp-broker-placeholder' },
76
+ },
77
+ },
78
+ profiles: new Map([
79
+ [
80
+ 'glm',
81
+ {
82
+ name: 'glm',
83
+ secret: 'glm',
84
+ kind: 'custom',
85
+ host: 'api.z.ai',
86
+ header: 'authorization',
87
+ scheme: 'bearer',
88
+ env: {},
89
+ dependencies: [],
90
+ },
91
+ ],
92
+ ]),
93
+ }
94
+ : null,
95
+ });
96
+ }
@@ -0,0 +1,50 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
3
+
4
+ /**
5
+ * An agent adapter `glm` for daemon tests that only starts behind impd's
6
+ * broker and takes the credential of a GLM gateway from it: the auth
7
+ * profile `glm`, a custom bearer secret `glm` for api.z.ai, behind the
8
+ * gateway at https://api.z.ai/api/anthropic with the placeholder
9
+ * `ANTHROPIC_AUTH_TOKEN`. Every guest spawn sleeps, with the binding's
10
+ * variables, when it has one, and `CLAUDE_CONFIG_DIR` set to
11
+ * `claude-config` in the guest folder. Every other member is the plain
12
+ * agent adapter's.
13
+ */
14
+ export function buildStubBrokeredGatewayAdapter(): AgentAdapter {
15
+ return buildMockAgentAdapter({
16
+ id: 'glm',
17
+ planGuestSpawn: (_opts, guest) => ({
18
+ bin: 'sleep',
19
+ args: ['30'],
20
+ files: {},
21
+ env: { ...guest.auth?.env, CLAUDE_CONFIG_DIR: `${guest.dir}/claude-config` },
22
+ }),
23
+ findAuthSelection: () => ({
24
+ brokerRequired: true,
25
+ gateway: {
26
+ id: 'glm',
27
+ baseURL: 'https://api.z.ai/api/anthropic',
28
+ auth: {
29
+ profiles: ['glm'],
30
+ placeholderEnv: { ANTHROPIC_AUTH_TOKEN: 'imp-broker-placeholder' },
31
+ },
32
+ },
33
+ profiles: new Map([
34
+ [
35
+ 'glm',
36
+ {
37
+ name: 'glm',
38
+ secret: 'glm',
39
+ kind: 'custom',
40
+ host: 'api.z.ai',
41
+ header: 'authorization',
42
+ scheme: 'bearer',
43
+ env: {},
44
+ dependencies: [],
45
+ },
46
+ ],
47
+ ]),
48
+ }),
49
+ });
50
+ }
@@ -0,0 +1,45 @@
1
+ type ChannelOpener<Address, Channel> = (address: Address) => Promise<Channel>;
2
+
3
+ interface StubChannelOpenerOptions {
4
+ // The position of the dial to hold, counting from 1; no dial is held
5
+ // without it.
6
+ readonly holdDial?: number;
7
+ }
8
+
9
+ /**
10
+ * A dial function that opens each connection with the opener at the dial's
11
+ * position in the list, and every dial past the end with the last one, so
12
+ * a test can send a first connection to one place and every reconnect to
13
+ * another. The dial at `holdDial` waits before it opens: `waitForHeld`
14
+ * resolves once that dial has started, and `releaseHeld` lets it go on.
15
+ * `countOpened` returns how many dials it has made.
16
+ */
17
+ export function buildStubChannelOpener<Address, Channel>(
18
+ openers: readonly [ChannelOpener<Address, Channel>, ...ChannelOpener<Address, Channel>[]],
19
+ options: StubChannelOpenerOptions = {},
20
+ ) {
21
+ const reached = Promise.withResolvers<void>();
22
+ const released = Promise.withResolvers<void>();
23
+ let opened = 0;
24
+
25
+ return {
26
+ open: async (address: Address): Promise<Channel> => {
27
+ opened++;
28
+
29
+ const opener = openers[Math.min(opened, openers.length) - 1] ?? openers[0];
30
+
31
+ if (opened === options.holdDial) {
32
+ reached.resolve();
33
+
34
+ await released.promise;
35
+ }
36
+
37
+ return opener(address);
38
+ },
39
+ countOpened: (): number => opened,
40
+ waitForHeld: (): Promise<void> => reached.promise,
41
+ releaseHeld: (): void => {
42
+ released.resolve();
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,13 @@
1
+ import { mock } from 'bun:test';
2
+ import type { Mock } from 'bun:test';
3
+ import type { ClaudeHeadlessRun } from '../agents/make-claude-headless-runner';
4
+
5
+ /**
6
+ * A stand-in for the Claude headless runner an adapter takes: it records
7
+ * each request and its event hooks, starts no turn, fires no hook, and
8
+ * hands back a handle whose stop does nothing. A test reads what the
9
+ * adapter asked for through the mock matchers.
10
+ */
11
+ export function buildStubClaudeHeadlessRun(): Mock<ClaudeHeadlessRun> {
12
+ return mock<ClaudeHeadlessRun>(() => ({ stop: () => {} }));
13
+ }
@@ -0,0 +1,33 @@
1
+ import type { ClientMachineDeps } from '../client/build-client-machine';
2
+
3
+ /**
4
+ * The screens a client machine opens, as side effects that each append
5
+ * their name, and the session or mode they were given, to `calls` in the
6
+ * order they run, and draw nothing.
7
+ */
8
+ export function buildStubClientScreens() {
9
+ const calls: string[] = [];
10
+
11
+ const deps: ClientMachineDeps = {
12
+ openHome: () => {
13
+ calls.push('openHome');
14
+ },
15
+ openAttached: (sessionID) => {
16
+ calls.push(`openAttached:${sessionID}`);
17
+ },
18
+ openOverlay: () => {
19
+ calls.push('openOverlay');
20
+ },
21
+ openHelp: () => {
22
+ calls.push('openHelp');
23
+ },
24
+ openPicker: (resume) => {
25
+ calls.push(`openPicker:${resume}`);
26
+ },
27
+ openEject: (sessionID) => {
28
+ calls.push(`openEject:${sessionID}`);
29
+ },
30
+ };
31
+
32
+ return { deps, calls };
33
+ }
@@ -0,0 +1,67 @@
1
+ import type { Clock } from '../shared/system-clock';
2
+
3
+ interface StubTimer {
4
+ readonly dueAt: number;
5
+ readonly callback: () => void;
6
+ }
7
+
8
+ /**
9
+ * A clock that moves only when the test advances it, for code that takes a
10
+ * clock in place of the wall clock and the runtime's timers. It starts at
11
+ * the given epoch milliseconds. `advance` moves the time forward and runs,
12
+ * in due order, every scheduled callback whose time has come, including
13
+ * one a callback schedules within the step. `collectPending` returns, in
14
+ * due order, how many milliseconds each callback still waiting has left,
15
+ * which lets a test wait until the code under test has scheduled one and
16
+ * check its delay.
17
+ */
18
+ export function buildStubClock(startMs: number) {
19
+ let time = startMs;
20
+ let nextID = 0;
21
+
22
+ const timers = new Map<number, StubTimer>();
23
+
24
+ const findDue = (until: number): [number, StubTimer] | null => {
25
+ let due: [number, StubTimer] | null = null;
26
+
27
+ for (const entry of timers) {
28
+ if (entry[1].dueAt <= until && (due === null || entry[1].dueAt < due[1].dueAt)) {
29
+ due = entry;
30
+ }
31
+ }
32
+
33
+ return due;
34
+ };
35
+
36
+ const clock: Clock = {
37
+ now: () => time,
38
+ schedule: (callback, delayMs) => {
39
+ const id = nextID++;
40
+
41
+ timers.set(id, { dueAt: time + Math.max(0, delayMs), callback });
42
+
43
+ return () => {
44
+ timers.delete(id);
45
+ };
46
+ },
47
+ };
48
+
49
+ return {
50
+ ...clock,
51
+ advance(ms: number): void {
52
+ const until = time + ms;
53
+
54
+ for (let due = findDue(until); due !== null; due = findDue(until)) {
55
+ timers.delete(due[0]);
56
+
57
+ time = due[1].dueAt;
58
+
59
+ due[1].callback();
60
+ }
61
+
62
+ time = until;
63
+ },
64
+ collectPending: (): number[] =>
65
+ [...timers.values()].map((timer) => timer.dueAt - time).toSorted((a, b) => a - b),
66
+ };
67
+ }
@@ -0,0 +1,81 @@
1
+ import type { DaemonContext } from '../daemon/daemon-context';
2
+ import { TargetAccess } from '../daemon/target-access';
3
+ import { toDaemonID } from '../shared/to-daemon-id';
4
+
5
+ /**
6
+ * The daemon a connection serves, for tests that drive one connection
7
+ * without a daemon behind it: build `atc/test`, daemon id `d-1`, a 64 KiB
8
+ * outbound queue, a day's idempotency retention, no sessions, `claude` as
9
+ * the last agent used, an empty fleet, a principal that reaches no target,
10
+ * every session bound to the `local` target and visible, and resyncs and
11
+ * detaches that do nothing. Every other
12
+ * member throws when called, naming itself, so a test that reaches one it
13
+ * did not wire fails loudly. An override replaces the member it names.
14
+ */
15
+ export function buildStubDaemonContext(overrides: Partial<DaemonContext> = {}): DaemonContext {
16
+ return {
17
+ build: 'atc/test',
18
+ daemonID: toDaemonID('d-1'),
19
+ queueBytes: 65_536,
20
+ idempotencyRetentionMs: 86_400_000,
21
+ collectSessions: () => [],
22
+ collectSpawnDirs: makeUnreached('collectSpawnDirs'),
23
+ collectAgents: makeUnreached('collectAgents'),
24
+ collectFleet: () => Promise.resolve([]),
25
+ loadLastUsedAgent: () => Promise.resolve('claude'),
26
+ findAdapter: makeUnreached('findAdapter'),
27
+ buildTargetAccess: () => new TargetAccess([]),
28
+ hasListedPrincipal: makeUnreached('hasListedPrincipal'),
29
+ findSessionGrant: () => ({ target: 'local', targetIdentity: 'local-pty' }),
30
+ findTargetIdentity: makeUnreached('findTargetIdentity'),
31
+ canSeeSession: () => true,
32
+ isSessionVisible: makeUnreached('isSessionVisible'),
33
+ findPermissionSession: makeUnreached('findPermissionSession'),
34
+ resolveSpawnParent: makeUnreached('resolveSpawnParent'),
35
+ resolveSpawnTarget: makeUnreached('resolveSpawnTarget'),
36
+ requireWorkspaceTarget: makeUnreached('requireWorkspaceTarget'),
37
+ buildDefaultWorkspaceDir: makeUnreached('buildDefaultWorkspaceDir'),
38
+ requireAgentTarget: makeUnreached('requireAgentTarget'),
39
+ findSource: makeUnreached('findSource'),
40
+ collectAlternateGitURLs: makeUnreached('collectAlternateGitURLs'),
41
+ checkRepositoryAccess: makeUnreached('checkRepositoryAccess'),
42
+ spawnSession: makeUnreached('spawnSession'),
43
+ killSession: makeUnreached('killSession'),
44
+ forgetSession: makeUnreached('forgetSession'),
45
+ revokeSessionAuth: makeUnreached('revokeSessionAuth'),
46
+ updateSessionAuth: makeUnreached('updateSessionAuth'),
47
+ updateSession: makeUnreached('updateSession'),
48
+ quitDaemon: makeUnreached('quitDaemon'),
49
+ ackSession: makeUnreached('ackSession'),
50
+ buildResumeCommand: makeUnreached('buildResumeCommand'),
51
+ readSessionScreen: makeUnreached('readSessionScreen'),
52
+ readSessionRecord: makeUnreached('readSessionRecord'),
53
+ loadSessionTranscript: makeUnreached('loadSessionTranscript'),
54
+ readEvents: makeUnreached('readEvents'),
55
+ readReport: makeUnreached('readReport'),
56
+ answerPermission: makeUnreached('answerPermission'),
57
+ restoreFleet: makeUnreached('restoreFleet'),
58
+ attachSession: makeUnreached('attachSession'),
59
+ detachSession: () => {},
60
+ detachClient: makeUnreached('detachClient'),
61
+ writeSessionInput: makeUnreached('writeSessionInput'),
62
+ writeSessionLine: makeUnreached('writeSessionLine'),
63
+ ejectSession: makeUnreached('ejectSession'),
64
+ adoptSession: makeUnreached('adoptSession'),
65
+ resizeSession: makeUnreached('resizeSession'),
66
+ resyncClient: () => Promise.resolve(),
67
+ getEffectiveDims: makeUnreached('getEffectiveDims'),
68
+ writeSessionMessage: makeUnreached('writeSessionMessage'),
69
+ readMessage: makeUnreached('readMessage'),
70
+ attachTap: makeUnreached('attachTap'),
71
+ detachTap: () => {},
72
+ ackMessage: makeUnreached('ackMessage'),
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ function makeUnreached(member: string): () => never {
78
+ return () => {
79
+ throw new Error(`the stub daemon context has no ${member}`);
80
+ };
81
+ }
@@ -0,0 +1,103 @@
1
+ import { waitFor } from './wait-for';
2
+
3
+ type DaemonAnswer = Readonly<Record<string, unknown>>;
4
+
5
+ interface StubRequest {
6
+ readonly m: string;
7
+ readonly p: DaemonAnswer;
8
+ readonly resolve: (answer: DaemonAnswer) => void;
9
+ }
10
+
11
+ interface StubDaemonRequestsOptions {
12
+ // A count the client under test raises each time it acts on something,
13
+ // such as a draw or a callback it makes.
14
+ readonly countReactions: () => number;
15
+
16
+ // Milliseconds each wait for a request or a reaction lasts before it
17
+ // rejects; five seconds when absent.
18
+ readonly timeoutMs?: number;
19
+
20
+ // The clock each wait reads its deadline from; the wall clock when absent.
21
+ readonly now?: () => number;
22
+
23
+ // Waits out the pause between a wait's checks; a real sleep when absent.
24
+ readonly wait?: (ms: number) => Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * A daemon request channel that answers only when the test says so. Every
29
+ * request sent through `sendRequest` waits until `answer` or
30
+ * `answerOldest` resolves it. Each of those waits for a request under the
31
+ * method to be waiting, resolves the latest or the oldest one, and then
32
+ * waits for the client to react: to send another request, or to raise the
33
+ * count `countReactions` reads. A wait that outlasts `timeoutMs` rejects:
34
+ * with `no <method> request is waiting for an answer` when nothing was
35
+ * sent under the method, and with `nothing reacted to the <method> answer`
36
+ * when the client never reacted. A test that takes the `now` and `wait`
37
+ * of a stub clock steps that timeout without waiting it out. `collectSent` returns the params of every
38
+ * request sent under a method, answered or not, in the order they were
39
+ * sent.
40
+ */
41
+ export function buildStubDaemonRequests(options: StubDaemonRequestsOptions) {
42
+ const sent: StubRequest[] = [];
43
+
44
+ const answered = new Set<StubRequest>();
45
+
46
+ const waitOptions = {
47
+ timeoutMs: options.timeoutMs ?? 5000,
48
+ now: options.now ?? Date.now,
49
+ wait: options.wait ?? Bun.sleep,
50
+ };
51
+
52
+ const countAll = () => sent.length + options.countReactions();
53
+
54
+ const resolveAndWait = async (request: Readonly<StubRequest>, value: DaemonAnswer) => {
55
+ const before = countAll();
56
+
57
+ answered.add(request);
58
+ request.resolve(value);
59
+
60
+ await waitFor(() => {
61
+ if (countAll() <= before) {
62
+ throw new Error(`nothing reacted to the ${request.m} answer`);
63
+ }
64
+ }, waitOptions);
65
+ };
66
+
67
+ return {
68
+ sendRequest(m: string, p: DaemonAnswer = {}): Promise<DaemonAnswer> {
69
+ return new Promise((resolve) => {
70
+ sent.push({ m, p, resolve });
71
+ });
72
+ },
73
+ async answer(m: string, value: DaemonAnswer): Promise<void> {
74
+ const request = await waitFor(() => {
75
+ const latest = sent.findLast((r) => r.m === m && !answered.has(r));
76
+
77
+ if (latest === undefined) {
78
+ throw new Error(`no ${m} request is waiting for an answer`);
79
+ }
80
+
81
+ return latest;
82
+ }, waitOptions);
83
+
84
+ await resolveAndWait(request, value);
85
+ },
86
+ async answerOldest(m: string, value: DaemonAnswer): Promise<void> {
87
+ const request = await waitFor(() => {
88
+ const oldest = sent.find((r) => r.m === m && !answered.has(r));
89
+
90
+ if (oldest === undefined) {
91
+ throw new Error(`no ${m} request is waiting for an answer`);
92
+ }
93
+
94
+ return oldest;
95
+ }, waitOptions);
96
+
97
+ await resolveAndWait(request, value);
98
+ },
99
+ collectSent(m: string): DaemonAnswer[] {
100
+ return sent.filter((request) => request.m === m).map((request) => request.p);
101
+ },
102
+ };
103
+ }
@@ -11,11 +11,11 @@ import type {
11
11
  import { LocalPTYProvider } from '../daemon/local-pty-provider';
12
12
 
13
13
  // One provider operation, in the order the daemon called it.
14
- type FixtureCall =
14
+ type StubDirCall =
15
15
  | { readonly op: 'transfer'; readonly dir: string; readonly bytes: number }
16
16
  | { readonly op: 'run'; readonly argv: readonly string[]; readonly cwd: string };
17
17
 
18
- interface FixtureDirOptions {
18
+ interface StubDirOptions {
19
19
  // Capabilities the provider declares as missing.
20
20
  readonly lacking?: readonly ExecutionCapability[];
21
21
 
@@ -25,22 +25,16 @@ interface FixtureDirOptions {
25
25
  }
26
26
 
27
27
  /**
28
- * An execution provider for tests whose host is a directory tree the test
29
- * owns: a transfer unpacks the archive with tar into the directory it is
30
- * given, and a command runs as a child process in its working directory.
31
- * Paths pass through unchanged, so a test points them into its own temp
32
- * tree. Every transfer and command is recorded in `calls`. Harnesses start
33
- * on a local pseudo-terminal, and each one's spec is recorded in
34
- * `harnesses`.
28
+ * The directory-tree provider the factory below builds, one per call.
35
29
  */
36
- export class FixtureDirProvider implements ExecutionProvider {
30
+ class StubDirProvider implements ExecutionProvider {
37
31
  readonly kind = 'fixture-dir';
38
32
 
39
33
  readonly remote = false;
40
34
 
41
35
  readonly capabilities: ExecutionCapabilities;
42
36
 
43
- readonly calls: FixtureCall[] = [];
37
+ readonly calls: StubDirCall[] = [];
44
38
 
45
39
  // Every harness the provider started, as the daemon specified it.
46
40
  readonly harnesses: HarnessSpec[] = [];
@@ -49,7 +43,7 @@ export class FixtureDirProvider implements ExecutionProvider {
49
43
 
50
44
  private readonly terminals = new LocalPTYProvider();
51
45
 
52
- constructor(options: FixtureDirOptions = {}) {
46
+ constructor(options: StubDirOptions = {}) {
53
47
  const lacking = new Set(options.lacking);
54
48
 
55
49
  this.capabilities = {
@@ -129,3 +123,17 @@ export class FixtureDirProvider implements ExecutionProvider {
129
123
 
130
124
  readonly dispose = (): void => {};
131
125
  }
126
+
127
+ /**
128
+ * Builds an execution provider of kind `fixture-dir` for tests whose host is
129
+ * a directory tree the test owns: a transfer unpacks the archive with tar
130
+ * into the directory it is given, and a command runs as a child process in
131
+ * its working directory. Paths pass through unchanged, so a test points them
132
+ * into its own temp tree. Every transfer and command is recorded in `calls`.
133
+ * Harnesses start on a local pseudo-terminal, and each one's spec is
134
+ * recorded in `harnesses`. Suspending or destroying a host rejects, since
135
+ * the test's own tree neither sleeps nor goes away.
136
+ */
137
+ export function buildStubDirProvider(options: StubDirOptions = {}): StubDirProvider {
138
+ return new StubDirProvider(options);
139
+ }
@@ -0,0 +1,112 @@
1
+ import type {
2
+ ExecutionCapabilities,
3
+ ExecutionProvider,
4
+ HarnessSpec,
5
+ } from '../daemon/execution-provider';
6
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
7
+
8
+ interface StubExecutionProviderConfig {
9
+ // The provider kind the daemon reports for the target.
10
+ readonly kind?: string;
11
+
12
+ // Capabilities that differ from a local pseudo-terminal's.
13
+ readonly capabilities?: Partial<ExecutionCapabilities>;
14
+
15
+ /**
16
+ * Called with each harness spec before the harness starts; a throw from
17
+ * it aborts the spawn.
18
+ */
19
+ readonly onSpawn?: (spec: HarnessSpec) => void;
20
+ }
21
+
22
+ interface StubExecutionProvider extends ExecutionProvider {
23
+ // Every host the daemon put to sleep, in order.
24
+ readonly suspended: string[];
25
+
26
+ // Every host the daemon destroyed, in order.
27
+ readonly destroyed: string[];
28
+
29
+ /**
30
+ * Rejects every host suspend with the error, recording nothing, until
31
+ * called with null.
32
+ */
33
+ readonly setSuspendFailure: (error: Readonly<Error> | null) => void;
34
+
35
+ /**
36
+ * Rejects every host destroy with the error, recording nothing, until
37
+ * called with null.
38
+ */
39
+ readonly setDestroyFailure: (error: Readonly<Error> | null) => void;
40
+ }
41
+
42
+ /**
43
+ * An execution provider for daemon tests that runs harnesses, transfers, and
44
+ * commands on this machine as the local pseudo-terminal provider does, with
45
+ * the capabilities the config changes on top of its own. It reports each
46
+ * harness spec to the config before starting it. A host suspend or
47
+ * destroy does nothing to the machine: it records the host in `suspended` or
48
+ * `destroyed` and resolves, or rejects with the error a failure setter gave.
49
+ * The daemon calls them only when the capabilities declare `suspend` or
50
+ * `destroy`.
51
+ */
52
+ export function buildStubExecutionProvider(
53
+ config: StubExecutionProviderConfig = {},
54
+ ): StubExecutionProvider {
55
+ const local = new LocalPTYProvider();
56
+
57
+ const onSpawn = config.onSpawn ?? (() => {});
58
+ const suspended: string[] = [];
59
+ const destroyed: string[] = [];
60
+
61
+ // The errors a suspend and a destroy reject with, or null while they
62
+ // succeed.
63
+ const failures: { suspend: Readonly<Error> | null; destroy: Readonly<Error> | null } = {
64
+ suspend: null,
65
+ destroy: null,
66
+ };
67
+
68
+ return {
69
+ kind: config.kind ?? 'stub',
70
+ remote: false,
71
+ capabilities: { ...local.capabilities, ...config.capabilities },
72
+ prepareHost: local.prepareHost,
73
+ spawnHarness: (spec) => {
74
+ onSpawn(spec);
75
+
76
+ return local.spawnHarness(spec);
77
+ },
78
+ transferArchive: local.transferArchive,
79
+ runCommand: local.runCommand,
80
+ suspendHost: (host) => {
81
+ const failure = failures.suspend;
82
+
83
+ if (failure !== null) {
84
+ return Promise.reject(failure);
85
+ }
86
+
87
+ suspended.push(host);
88
+
89
+ return Promise.resolve();
90
+ },
91
+ destroyHost: (host) => {
92
+ const failure = failures.destroy;
93
+
94
+ if (failure !== null) {
95
+ return Promise.reject(failure);
96
+ }
97
+
98
+ destroyed.push(host);
99
+
100
+ return Promise.resolve();
101
+ },
102
+ dispose: local.dispose,
103
+ suspended,
104
+ destroyed,
105
+ setSuspendFailure: (error) => {
106
+ failures.suspend = error;
107
+ },
108
+ setDestroyFailure: (error) => {
109
+ failures.destroy = error;
110
+ },
111
+ };
112
+ }