@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,53 @@
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 StubPTYProviderConfig {
9
+ // The provider kind the stand-in reports, such as the kind a target's
10
+ // config selects; the local provider's own kind when absent.
11
+ readonly kind?: string;
12
+
13
+ // Capabilities that differ from the local provider's.
14
+ readonly capabilities?: Partial<ExecutionCapabilities>;
15
+
16
+ // Called with each harness spec before the harness starts.
17
+ readonly onSpawn?: (spec: HarnessSpec) => void;
18
+ readonly prepareHost?: ExecutionProvider['prepareHost'];
19
+ readonly suspendHost?: ExecutionProvider['suspendHost'];
20
+ readonly destroyHost?: ExecutionProvider['destroyHost'];
21
+ }
22
+
23
+ /**
24
+ * An execution provider for daemon tests that runs every harness on a real
25
+ * local pseudo-terminal, as the `local-pty` provider does, under whatever
26
+ * kind and capabilities the test gives it, so a target of any kind starts
27
+ * a real process. Each spawn is reported to `onSpawn` first. Host
28
+ * readying, sleeping, and destroying take the test's own operations when
29
+ * given, and the local provider's otherwise: readying always succeeds, and
30
+ * a sleep or a destroy rejects.
31
+ */
32
+ export function buildStubPTYProvider(config: StubPTYProviderConfig = {}): ExecutionProvider {
33
+ const local = new LocalPTYProvider();
34
+
35
+ const onSpawn = config.onSpawn ?? (() => {});
36
+
37
+ return {
38
+ kind: config.kind ?? local.kind,
39
+ remote: false,
40
+ capabilities: { ...local.capabilities, ...config.capabilities },
41
+ prepareHost: config.prepareHost ?? local.prepareHost,
42
+ spawnHarness: (spec) => {
43
+ onSpawn(spec);
44
+
45
+ return local.spawnHarness(spec);
46
+ },
47
+ transferArchive: local.transferArchive,
48
+ runCommand: local.runCommand,
49
+ suspendHost: config.suspendHost ?? local.suspendHost,
50
+ destroyHost: config.destroyHost ?? local.destroyHost,
51
+ dispose: local.dispose,
52
+ };
53
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The script of a stand-in `atc` that records each run: it appends
3
+ * `args:<arguments>`, `session:<$ATC_SESSION_ID>`, and `stdin:<what it
4
+ * read>` to the log, each on its own line, and exits 0.
5
+ */
6
+ export function buildStubRecordingATC(log: string): string {
7
+ return `#!/bin/sh
8
+ { printf 'args:%s\\n' "$*"; printf 'session:%s\\n' "$ATC_SESSION_ID"; printf 'stdin:'; cat; printf '\\n'; } >> '${log}'
9
+ `;
10
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The script of a stand-in git filter driver that records each run by
3
+ * appending the line `ran` to the record file and passes its input through
4
+ * unchanged, as a smudge filter that changes nothing does, so a test can
5
+ * tell whether git ran a filter the host configured.
6
+ */
7
+ export function buildStubRecordingFilter(record: string): string {
8
+ return `#!/bin/sh\necho ran >> '${record}'\ncat\n`;
9
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The script of a stand-in `git` that records each run by appending the
3
+ * line `ran` to the record file, then exits 0 without doing anything else,
4
+ * so a test can tell whether the code under test ran git at all.
5
+ */
6
+ export function buildStubRecordingGit(record: string): string {
7
+ return `#!/bin/sh\necho ran >> '${record}'\n`;
8
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The script of a stand-in `gh` that is signed in. It appends each command
3
+ * line it runs to `$HOME/gh-argv`. `gh config …` prints `https`. A
4
+ * repository listing of the signed-in account, `gh repo list --limit …`,
5
+ * prints one public repository, `me/dots`, and a listing of an owner,
6
+ * `gh repo list <owner> --limit …`, prints one private repository,
7
+ * `acme/app`, each in the JSON shape `gh repo list` prints.
8
+ */
9
+ export function buildStubSignedInGH(): string {
10
+ return `#!/bin/sh
11
+ printf '%s\\n' "$*" >> "$HOME/gh-argv"
12
+ case "$1 $3" in
13
+ "config "*) echo https ;;
14
+ "repo --limit") echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
15
+ *) echo '[{"nameWithOwner":"acme/app","description":"","isPrivate":true,"url":"https://github.com/acme/app","sshUrl":"git@github.com:acme/app.git"}]' ;;
16
+ esac
17
+ `;
18
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The script of a stand-in `gh` that is signed out: every command prints
3
+ * gh's own sign-in hint to stderr and exits 4, as the real one does before
4
+ * `gh auth login`. The hint is gh 2.99.0's text, byte for byte.
5
+ */
6
+ export function buildStubSignedOutGH(): string {
7
+ return "#!/bin/sh\nprintf '%s\\n%s\\n' 'To get started with GitHub CLI, please run: gh auth login' 'Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.' >&2\nexit 4\n";
8
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExecutionProvider, HarnessHandle } from '../daemon/execution-provider';
2
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
3
+
4
+ /**
5
+ * An execution provider of kind `no-forced-kill` that runs every harness on
6
+ * this machine through the local terminal provider, but whose harnesses
7
+ * have no forced kill to send, as a remote provider's have none: a harness
8
+ * that ignores its kill keeps running.
9
+ */
10
+ export function buildStubSoftKillProvider(): ExecutionProvider {
11
+ const local = new LocalPTYProvider();
12
+
13
+ return {
14
+ kind: 'no-forced-kill',
15
+ remote: false,
16
+ capabilities: local.capabilities,
17
+ prepareHost: local.prepareHost,
18
+ spawnHarness: (spec): HarnessHandle => {
19
+ const { killForced: _killForced, ...handle } = local.spawnHarness(spec);
20
+
21
+ return handle;
22
+ },
23
+ transferArchive: local.transferArchive,
24
+ runCommand: local.runCommand,
25
+ suspendHost: local.suspendHost,
26
+ destroyHost: local.destroyHost,
27
+ dispose: local.dispose,
28
+ };
29
+ }
@@ -0,0 +1,58 @@
1
+ import type { ExecutionTarget } from '../daemon/build-execution-targets';
2
+ import { buildTargetIdentity } from '../daemon/build-target-identity';
3
+ import type { TargetConfig } from '../shared/collect-targets';
4
+ import { buildStubPTYProvider } from './build-stub-pty-provider';
5
+
6
+ // What a target's provider does in place of the local one's: the
7
+ // capabilities it adds and the host preparation, sleep, and destroy it runs.
8
+ type HostOverride = Omit<
9
+ NonNullable<Parameters<typeof buildStubPTYProvider>[0]>,
10
+ 'kind' | 'onSpawn'
11
+ >;
12
+
13
+ interface StubTargetsConfig {
14
+ // Each spawn appends its target's id here, in order.
15
+ readonly spawned: string[];
16
+
17
+ // Host operations of their own for the targets they name.
18
+ readonly hosts?: Readonly<Record<string, HostOverride>>;
19
+ }
20
+
21
+ /**
22
+ * The daemon's execution targets for configured target entries, as the
23
+ * config load or the targets section parse gives them, in their order:
24
+ * each `local-pty` entry runs its harnesses on a real local
25
+ * pseudo-terminal through a stand-in provider that appends the entry's id
26
+ * to `spawned` on every spawn and takes the host operations `hosts` gives
27
+ * it, and an entry of any other kind has no provider, as a provider this
28
+ * daemon lacks leaves it. Each target's identity is the real one for its
29
+ * kind and options.
30
+ */
31
+ export function buildStubTargets(
32
+ targets: readonly TargetConfig[],
33
+
34
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the stand-in appends to the caller's spawn list
35
+ config: StubTargetsConfig,
36
+ ): ExecutionTarget[] {
37
+ const hosts: Readonly<Record<string, HostOverride>> = { ...config.hosts };
38
+
39
+ const providers = new Map(
40
+ targets.map((target) => [
41
+ target.id,
42
+ buildStubPTYProvider({
43
+ onSpawn: () => {
44
+ config.spawned.push(target.id);
45
+ },
46
+ ...hosts[target.id],
47
+ }),
48
+ ]),
49
+ );
50
+
51
+ return targets.map((target) => ({
52
+ id: target.id,
53
+ kind: target.provider,
54
+ options: target.options,
55
+ identity: buildTargetIdentity(target.provider, target.options),
56
+ provider: target.provider === 'local-pty' ? (providers.get(target.id) ?? null) : null,
57
+ }));
58
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A terminal that keeps every chunk written to it, in order, and draws
3
+ * nothing. `getText` returns everything written. `mark` returns a position
4
+ * at the end of what is written so far, and `getTextSince` returns only
5
+ * what was written after that position, so a test reads only the output of
6
+ * the action it checks.
7
+ */
8
+ export function buildStubTerminal() {
9
+ const chunks: string[] = [];
10
+
11
+ return {
12
+ write: (chunk: string): void => {
13
+ chunks.push(chunk);
14
+ },
15
+ getText: (): string => chunks.join(''),
16
+ mark: (): number => chunks.length,
17
+ getTextSince: (mark: number): string => chunks.slice(mark).join(''),
18
+ };
19
+ }
@@ -0,0 +1,48 @@
1
+ // One timer the scheduler started: its delay, what it runs, and whether it
2
+ // is still pending, has run, or was cancelled.
3
+ interface StubTimer {
4
+ readonly ms: number;
5
+ readonly onTimeout: () => void;
6
+ state: 'pending' | 'ran' | 'cancelled';
7
+ }
8
+
9
+ /**
10
+ * A timeout scheduler for code that takes one in place of `setTimeout`: no
11
+ * timer ever runs on its own. `schedule` records the timer and returns what
12
+ * cancels it, as a real scheduler does; `runTimer` runs the oldest pending
13
+ * timer of the given delay and throws when none is pending, so a test both
14
+ * proves the code asked for that delay and moves time past it.
15
+ * `collectDelays` returns the delay of every timer in the order the code
16
+ * started them, and `collectPendingDelays` those neither run nor cancelled.
17
+ */
18
+ export function buildStubTimeoutScheduler() {
19
+ const timers: StubTimer[] = [];
20
+
21
+ return {
22
+ schedule: (onTimeout: () => void, ms: number): (() => void) => {
23
+ const timer: StubTimer = { ms, onTimeout, state: 'pending' };
24
+
25
+ timers.push(timer);
26
+
27
+ return () => {
28
+ timer.state = timer.state === 'pending' ? 'cancelled' : timer.state;
29
+ };
30
+ },
31
+ runTimer: (ms: number): void => {
32
+ const timer = timers.find(
33
+ (candidate) => candidate.state === 'pending' && candidate.ms === ms,
34
+ );
35
+
36
+ if (timer === undefined) {
37
+ throw new Error(`no pending timer of ${ms} ms`);
38
+ }
39
+
40
+ timer.state = 'ran';
41
+
42
+ timer.onTimeout();
43
+ },
44
+ collectDelays: (): number[] => timers.map((timer) => timer.ms),
45
+ collectPendingDelays: (): number[] =>
46
+ timers.filter((timer) => timer.state === 'pending').map((timer) => timer.ms),
47
+ };
48
+ }
@@ -0,0 +1,47 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `claude` for the TUI suites. It prints
7
+ * `FAKE_CLAUDE_UP args: ` and its arguments at start and again on every
8
+ * SIGWINCH, as the real one repaints on a resize. It reports `SessionStart`
9
+ * through the real reporter with session id `fake-1` and transcript
10
+ * `$HOME/fake-transcript.jsonl`, then a permission `Notification`, and
11
+ * echoes each line it reads as `GOT:<line>` for five minutes. Scenario files
12
+ * in `$HOME` change it: `fake-claude-events.jsonl` replaces the
13
+ * notification with its hook lines, and a run with `--resume` among its
14
+ * arguments touches `fake-claude-resume-held` and waits, before it prints
15
+ * or reports anything, while `fake-claude-hold-resume` exists.
16
+ */
17
+ export function buildStubTUIClaude(): string {
18
+ const report = `"${process.execPath}" "${CLI_PATH}" hook-report`;
19
+
20
+ return `#!/usr/bin/env bash
21
+ ARGS="$*"
22
+ case "$ARGS" in *--resume*)
23
+ if [ -f "$HOME/fake-claude-hold-resume" ]; then
24
+ touch "$HOME/fake-claude-resume-held"
25
+ while [ -f "$HOME/fake-claude-hold-resume" ]; do sleep 0.05; done
26
+ fi ;;
27
+ esac
28
+ paint() { echo "FAKE_CLAUDE_UP args: $ARGS"; }
29
+ trap paint WINCH
30
+ paint
31
+ printf '{"hook_event_name":"SessionStart","session_id":"fake-1","transcript_path":"'"$HOME"'/fake-transcript.jsonl"}' | ${report}
32
+ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
33
+ while IFS= read -r ev; do
34
+ [ -n "$ev" ] || continue
35
+ sleep 0.3
36
+ printf '%s' "$ev" | ${report}
37
+ done < "$HOME/fake-claude-events.jsonl"
38
+ else
39
+ sleep 0.3
40
+ printf '{"hook_event_name":"Notification","session_id":"fake-1","message":"needs permission"}' | ${report}
41
+ fi
42
+ # bash 3.2 read -t takes whole seconds; a fraction times out immediately
43
+ for _ in $(seq 1 300); do
44
+ if read -t 1 -r line; then echo "GOT:$line"; fi
45
+ done
46
+ `;
47
+ }
@@ -0,0 +1,53 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `grok` for the TUI suites. It prints
7
+ * `FAKE_GROK_UP args: ` and its arguments at start and again on every
8
+ * SIGWINCH, reports `session_start` through the real reporter with session
9
+ * id `fake-grok-1` and its working directory, then a `permission_prompt`
10
+ * notification, prints `FAKE_GROK_HOOKS_DONE`, and echoes each line it
11
+ * reads as `GOT:<line>` for five minutes. Scenario files in `$HOME` change
12
+ * it: `fake-grok-events.jsonl` replaces the notification with its hook
13
+ * lines, `fake-grok-hold-start` skips every report, and while
14
+ * `fake-grok-defer-start` exists it touches `fake-grok-start-deferred` and
15
+ * waits before it reports.
16
+ */
17
+ export function buildStubTUIGrok(): string {
18
+ const report = `"${process.execPath}" "${CLI_PATH}" hook-report`;
19
+
20
+ return `#!/usr/bin/env bash
21
+ ARGS="$*"
22
+ paint() { echo "FAKE_GROK_UP args: $ARGS"; }
23
+ trap paint WINCH
24
+ paint
25
+ idle() {
26
+ for _ in $(seq 1 300); do
27
+ if read -t 1 -r line; then echo "GOT:$line"; fi
28
+ done
29
+ }
30
+ if [ -f "$HOME/fake-grok-hold-start" ]; then
31
+ idle
32
+ exit 0
33
+ fi
34
+ if [ -f "$HOME/fake-grok-defer-start" ]; then
35
+ touch "$HOME/fake-grok-start-deferred"
36
+ while [ -f "$HOME/fake-grok-defer-start" ]; do sleep 0.05; done
37
+ fi
38
+ printf '{"hookEventName":"session_start","sessionId":"fake-grok-1","cwd":"%s"}' "$PWD" | ${report}
39
+ if [ -f "$HOME/fake-grok-events.jsonl" ]; then
40
+ sleep 0.3
41
+ while IFS= read -r ev; do
42
+ [ -n "$ev" ] || continue
43
+ printf '%s' "$ev" | ${report}
44
+ sleep 0.2
45
+ done < "$HOME/fake-grok-events.jsonl"
46
+ else
47
+ sleep 0.3
48
+ printf '{"hookEventName":"notification","sessionId":"fake-grok-1","notificationType":"permission_prompt","message":"allow edit?"}' | ${report}
49
+ fi
50
+ echo "FAKE_GROK_HOOKS_DONE"
51
+ idle
52
+ `;
53
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A clock for a polling wait that moves only when the wait waits it out:
3
+ * `now` starts at zero, and `wait` records the milliseconds it was asked to
4
+ * wait in `waits`, moves `now` forward by them, and resolves at once. A
5
+ * deadline then passes after a known number of attempts, with no real time
6
+ * spent.
7
+ */
8
+ export function buildStubWaitClock() {
9
+ const waits: number[] = [];
10
+ let elapsed = 0;
11
+
12
+ return {
13
+ waits,
14
+ now: () => elapsed,
15
+ wait: (ms: number): Promise<void> => {
16
+ waits.push(ms);
17
+
18
+ elapsed += ms;
19
+
20
+ return Promise.resolve();
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,37 @@
1
+ import { buildTargetIdentity } from '../daemon/build-target-identity';
2
+ import type { DaemonOptions } from '../daemon/daemon';
3
+ import type { ExecutionProvider } from '../daemon/execution-provider';
4
+ import { collectTargets } from '../shared/collect-targets';
5
+
6
+ interface RawTargetConfig {
7
+ // The raw `targets` key of a config file.
8
+ readonly targets?: unknown;
9
+
10
+ // The raw `defaultTarget` key of a config file.
11
+ readonly defaultTarget?: unknown;
12
+ }
13
+
14
+ /**
15
+ * Turns the raw target keys of a config file into daemon options through
16
+ * the real parse. Each parsed target takes the provider that the factory
17
+ * for its kind builds for its id, or no provider when no factory serves its
18
+ * kind, and every parse error passes through as a target config error.
19
+ */
20
+ export function buildTargetOptionsFromConfig(
21
+ raw: RawTargetConfig,
22
+ factories: ReadonlyMap<string, (id: string) => ExecutionProvider>,
23
+ ): Required<Pick<DaemonOptions, 'targets' | 'defaultTarget' | 'targetErrors'>> {
24
+ const parsed = collectTargets(raw.targets, raw.defaultTarget);
25
+
26
+ return {
27
+ targets: parsed.targets.map((target) => ({
28
+ id: target.id,
29
+ kind: target.provider,
30
+ options: target.options,
31
+ identity: buildTargetIdentity(target.provider, target.options),
32
+ provider: factories.get(target.provider)?.(target.id) ?? null,
33
+ })),
34
+ defaultTarget: parsed.defaultTarget,
35
+ targetErrors: parsed.errors,
36
+ };
37
+ }
@@ -0,0 +1,75 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export interface TreeProcess {
5
+ readonly pid: number;
6
+ readonly argv: readonly string[];
7
+ readonly env: Readonly<Record<string, string>>;
8
+ }
9
+
10
+ /**
11
+ * Collects the argv and environment of every live process descended from
12
+ * the root process, read from `/proc`, so a test can check what a child
13
+ * was started with while it still runs. The root itself is left out. A
14
+ * process that exits during the scan is left out too. Linux only, since
15
+ * only Linux has `/proc`.
16
+ */
17
+ export async function collectProcessTree(rootPID: number): Promise<TreeProcess[]> {
18
+ const entries = await readdir('/proc');
19
+
20
+ const pids = entries.filter((entry) => /^\d+$/u.test(entry));
21
+
22
+ const parents = new Map<string, string>();
23
+
24
+ for (const pid of pids) {
25
+ const stat = await readFile(join('/proc', pid, 'stat'), 'utf8').catch(() => '');
26
+
27
+ // The fields after the parenthesised command name start with the state,
28
+ // then the parent pid.
29
+ parents.set(pid, stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1] ?? '');
30
+ }
31
+
32
+ const root = String(rootPID);
33
+ const tree: TreeProcess[] = [];
34
+
35
+ for (const pid of pids) {
36
+ if (!isDescendant(parents, pid, root)) {
37
+ continue;
38
+ }
39
+
40
+ const cmdline = await readFile(join('/proc', pid, 'cmdline'), 'utf8').catch(() => null);
41
+ const environ = await readFile(join('/proc', pid, 'environ'), 'utf8').catch(() => null);
42
+
43
+ if (cmdline === null || environ === null) {
44
+ continue;
45
+ }
46
+
47
+ tree.push({
48
+ pid: Number(pid),
49
+ argv: splitNullTerminated(cmdline),
50
+ env: Object.fromEntries(
51
+ splitNullTerminated(environ).map((entry) => {
52
+ const equals = entry.indexOf('=');
53
+
54
+ return [entry.slice(0, equals), entry.slice(equals + 1)];
55
+ }),
56
+ ),
57
+ });
58
+ }
59
+
60
+ return tree;
61
+ }
62
+
63
+ function isDescendant(parents: ReadonlyMap<string, string>, pid: string, root: string): boolean {
64
+ let ancestor = parents.get(pid);
65
+
66
+ while (ancestor !== undefined && ancestor !== root) {
67
+ ancestor = parents.get(ancestor);
68
+ }
69
+
70
+ return ancestor === root;
71
+ }
72
+
73
+ function splitNullTerminated(text: string): string[] {
74
+ return text.split('\0').filter((entry) => entry !== '');
75
+ }
@@ -0,0 +1,117 @@
1
+ import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { $ } from 'bun';
4
+ import { setupTempDir } from './setup-temp-dir';
5
+
6
+ interface GitFixtureConfig {
7
+ // The temp directory's name prefix.
8
+ readonly prefix?: string;
9
+ }
10
+
11
+ /**
12
+ * A bare upstream at `upstream.git` on branch `main` and a work clone of it
13
+ * at `work`, both in a fresh temp directory, holding one pushed commit that
14
+ * adds `README.md`. The clone commits as `atc <atc@example.com>` without
15
+ * signing, and its origin is this fixture's own upstream. `sha` is that
16
+ * commit's id, the same in every fixture. `env` is the process environment
17
+ * without any `GIT_*` variable and with the host's system and global git
18
+ * config switched off: a git hook exports `GIT_DIR` and friends, which
19
+ * would point git at the repository running the hook, and a system-wide Git
20
+ * LFS install adds a pre-push hook that refuses the fixture. Run every git
21
+ * command a test adds with it.
22
+ *
23
+ * The first call in a process builds a template pair under the test home,
24
+ * and each call copies it, so a fixture costs a copy instead of a run of
25
+ * git commands. The directory is removed once the current test finishes,
26
+ * so it must run inside a test; disposal removes it sooner, and a second
27
+ * removal does nothing.
28
+ */
29
+ export async function createGitFixture(config: GitFixtureConfig = {}) {
30
+ const env = buildGitEnv(process.env);
31
+
32
+ const template = await resolveTemplate();
33
+
34
+ const tmp = setupTempDir(config.prefix ?? 'atc-git-fixture-');
35
+ const upstream = join(tmp.dir, 'upstream.git');
36
+ const work = join(tmp.dir, 'work');
37
+
38
+ cpSync(template.dir, tmp.dir, { recursive: true });
39
+
40
+ // The copied clone's config still holds the template's upstream as its
41
+ // origin; it is pointed at this fixture's own upstream instead, so a push
42
+ // never reaches the template or another fixture.
43
+ const gitConfig = join(work, '.git', 'config');
44
+
45
+ writeFileSync(
46
+ gitConfig,
47
+ readFileSync(gitConfig, 'utf8').replace(
48
+ `url = ${join(template.dir, 'upstream.git')}`,
49
+ `url = ${upstream}`,
50
+ ),
51
+ );
52
+
53
+ return {
54
+ dir: tmp.dir,
55
+ env,
56
+ upstream,
57
+ work,
58
+ sha: template.sha,
59
+ [Symbol.asyncDispose]: tmp[Symbol.asyncDispose],
60
+ };
61
+ }
62
+
63
+ function buildGitEnv(
64
+ source: Readonly<Record<string, string | undefined>>,
65
+ ): Record<string, string | undefined> {
66
+ return {
67
+ ...Object.fromEntries(Object.entries(source).filter(([name]) => !name.startsWith('GIT_'))),
68
+ GIT_CONFIG_NOSYSTEM: '1',
69
+ GIT_CONFIG_GLOBAL: '/dev/null',
70
+ };
71
+ }
72
+
73
+ interface GitTemplate {
74
+ readonly dir: string;
75
+ readonly sha: string;
76
+ }
77
+
78
+ // The template pair, built once per process.
79
+ let template: Promise<GitTemplate> | null = null;
80
+
81
+ function resolveTemplate(): Promise<GitTemplate> {
82
+ template ??= createTemplate();
83
+
84
+ return template;
85
+ }
86
+
87
+ // The template lives under the test home, which the test script removes
88
+ // when the run ends.
89
+ async function createTemplate(): Promise<GitTemplate> {
90
+ const env = buildGitEnv(process.env);
91
+ const home = process.env['ATC_TEST_HOME'];
92
+
93
+ if (home === undefined) {
94
+ throw new Error('a git fixture needs the test home; run `bun run test`');
95
+ }
96
+
97
+ const dir = mkdtempSync(join(home, 'git-fixture-template-'));
98
+ const upstream = join(dir, 'upstream.git');
99
+ const work = join(dir, 'work');
100
+
101
+ await $`git init --quiet --bare --template= --initial-branch=main ${upstream}`.env(env).quiet();
102
+ await $`git clone --quiet --template= ${upstream} ${work}`.env(env).quiet();
103
+ await $`git config user.name atc`.env(env).cwd(work).quiet();
104
+ await $`git config user.email atc@example.com`.env(env).cwd(work).quiet();
105
+ await $`git config commit.gpgsign false`.env(env).cwd(work).quiet();
106
+ await $`git config tag.gpgsign false`.env(env).cwd(work).quiet();
107
+
108
+ writeFileSync(join(work, 'README.md'), 'hello\n');
109
+
110
+ await $`git add README.md`.env(env).cwd(work).quiet();
111
+ await $`git commit --quiet -m initial`.env(env).cwd(work).quiet();
112
+ await $`git push --quiet origin main`.env(env).cwd(work).quiet();
113
+
114
+ const head = await $`git rev-parse HEAD`.env(env).cwd(work).quiet().text();
115
+
116
+ return { dir, sha: head.trim() };
117
+ }
@@ -11,24 +11,26 @@ let template: Promise<string> | undefined;
11
11
  * that needs a ready store skips the migration ladder. Each copy is a file of
12
12
  * its own; every copy holds the same daemon id, minted when the template was
13
13
  * migrated. The template sits in the test home, which the test script removes
14
- * on exit.
14
+ * on exit, under a name of its own per process, since parallel test processes
15
+ * share that home. It rejects when `ATC_TEST_HOME` is unset, as it is
16
+ * outside the test script.
15
17
  */
16
18
  export async function createMigratedStateDB(dbPath: string): Promise<void> {
17
- template ??= createTemplateStateDB();
18
-
19
- const templatePath = await template;
20
-
21
- copyFileSync(templatePath, dbPath);
22
- }
23
-
24
- async function createTemplateStateDB(): Promise<string> {
25
19
  const home = process.env['ATC_TEST_HOME'];
26
20
 
27
21
  if (home === undefined) {
28
22
  throw new Error('ATC_TEST_HOME is unset; run the tests through `bun run test`');
29
23
  }
30
24
 
31
- const path = join(home, 'migrated-state.db');
25
+ template ??= createTemplateStateDB(home);
26
+
27
+ const templatePath = await template;
28
+
29
+ copyFileSync(templatePath, dbPath);
30
+ }
31
+
32
+ async function createTemplateStateDB(home: string): Promise<string> {
33
+ const path = join(home, `migrated-state-${String(process.pid)}.db`);
32
34
 
33
35
  const store = await StateStore.open(path);
34
36