@zgeoff/atc 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +62 -13
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ import type { FleetCaller, FleetFeature } from '../mcp/types';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
+ import { DAEMON_FEATURES } from '../protocol/daemon-features';
4
+
5
+ /**
6
+ * One request a stub fleet caller received: the method and each further
7
+ * argument the sender passed, with an argument left undefined left out.
8
+ */
9
+ interface StubFleetRequest {
10
+ readonly m: string;
11
+ readonly p?: Readonly<Record<string, unknown>>;
12
+ readonly required?: readonly DaemonFeature[];
13
+ readonly principal?: string;
14
+ }
15
+
16
+ type StubFleetAnswer = (
17
+ request: StubFleetRequest,
18
+ ) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
19
+
20
+ interface StubFleetCallerConfig {
21
+ // Gives the reply to one request: a value, a promise, or a throw, which
22
+ // the caller turns into a rejection, as a daemon refusal arrives.
23
+ readonly answer?: StubFleetAnswer;
24
+
25
+ // The features the caller reports its daemon announced.
26
+ readonly features?: readonly FleetFeature[];
27
+ }
28
+
29
+ /**
30
+ * A fleet caller that stands in for a daemon connection: it records every
31
+ * request in `requests`, in the order sent, and replies through the
32
+ * config's `answer`, by default an empty record. It reports the config's
33
+ * `features`, by default every feature a current daemon announces, and it
34
+ * checks no request's required features against them, so a test that needs
35
+ * that refusal uses a real connection.
36
+ */
37
+ export function buildStubFleetCaller(
38
+ config: StubFleetCallerConfig = {},
39
+ ): FleetCaller & { readonly requests: readonly StubFleetRequest[] } {
40
+ const answer: StubFleetAnswer = config.answer ?? (() => ({}));
41
+ const features: readonly FleetFeature[] = config.features ?? DAEMON_FEATURES;
42
+ const requests: StubFleetRequest[] = [];
43
+
44
+ return {
45
+ requests,
46
+ sendRequest: (m, p, required, principal) => {
47
+ const request: StubFleetRequest = {
48
+ m,
49
+ ...(p === undefined ? {} : { p }),
50
+ ...(required === undefined ? {} : { required }),
51
+ ...(principal === undefined ? {} : { principal }),
52
+ };
53
+
54
+ requests.push(request);
55
+
56
+ return Promise.try(() => answer(request));
57
+ },
58
+ readFeatures: () => Promise.resolve(new Set(features)),
59
+ };
60
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The script of a stand-in `gh` that starts a child of its own and waits on
3
+ * it, as a gh extension might. It writes its own process ID, which is also
4
+ * the ID of the process group it leads when started detached, and its
5
+ * child's ID to `pidsFile`, one per line, moving the file into place so it
6
+ * appears whole. The child runs until it is killed.
7
+ */
8
+ export function buildStubForkingGH(pidsFile: string): string {
9
+ return `#!/bin/sh\nsh -c 'sleep 30' &\nprintf '%s\\n%s\\n' $$ $! > '${pidsFile}.tmp'\nmv '${pidsFile}.tmp' '${pidsFile}'\nwait\n`;
10
+ }
@@ -0,0 +1,50 @@
1
+ interface StubGHReply {
2
+ readonly stdout?: string;
3
+ readonly stderr?: string;
4
+ readonly exitCode?: number;
5
+ }
6
+
7
+ interface StubGHOptions {
8
+ // What each command prints and exits with, keyed by its first argument,
9
+ // such as `repo` or `config`; `hang` waits until the command is killed.
10
+ // A command without a reply prints nothing and exits 0.
11
+ readonly replies: Readonly<Record<string, StubGHReply | 'hang'>>;
12
+
13
+ // A file each run appends its arguments to, as one line.
14
+ readonly argvFile?: string;
15
+ }
16
+
17
+ /**
18
+ * The script of a stand-in `gh` that answers each command with the reply
19
+ * the test gave for its first argument: the stdout and stderr verbatim,
20
+ * then the exit code, 0 when unset. A `hang` reply waits until it is
21
+ * killed, as a gh that never answers does.
22
+ */
23
+ export function buildStubGH(options: StubGHOptions): string {
24
+ const record =
25
+ options.argvFile === undefined
26
+ ? ''
27
+ : `printf '%s\\n' "$*" >> ${toShellLiteral(options.argvFile)}\n`;
28
+
29
+ const cases = Object.entries(options.replies)
30
+ .map(([command, reply]) => ` ${toShellLiteral(command)}) ${renderReply(reply)} ;;\n`)
31
+ .join('');
32
+
33
+ return `#!/bin/sh\n${record}case "$1" in\n${cases}esac\n`;
34
+ }
35
+
36
+ function toShellLiteral(value: string): string {
37
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
38
+ }
39
+
40
+ function renderReply(reply: StubGHReply | 'hang'): string {
41
+ if (reply === 'hang') {
42
+ return 'exec sleep 30';
43
+ }
44
+
45
+ return [
46
+ `printf '%s' ${toShellLiteral(reply.stdout ?? '')}`,
47
+ `printf '%s' ${toShellLiteral(reply.stderr ?? '')} >&2`,
48
+ `exit ${reply.exitCode ?? 0}`,
49
+ ].join('; ');
50
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The script of a stand-in `atc` whose daemon hands its socket to a
3
+ * replacement: it moves the socket listening at `listeningPath` to
4
+ * `$HOME/atc-daemon.sock`, where a client of a daemon on that home dials,
5
+ * and exits 0. Write it with `createStubBin`.
6
+ */
7
+ export function buildStubHandoffDaemon(listeningPath: string): string {
8
+ return `#!/usr/bin/env bash
9
+ mv '${listeningPath}' "$HOME/atc-daemon.sock"
10
+ `;
11
+ }
@@ -0,0 +1,50 @@
1
+ import type { HarnessRelay } from '../daemon/execution-provider';
2
+
3
+ /**
4
+ * A harness relay held in memory, as a provider hands one to the daemon for
5
+ * a guest's connection. `sendLine` delivers a value as one JSON line from
6
+ * the guest to every line listener, `written` holds every line written back
7
+ * to the guest, parsed, in order, and `isClosed` turns true once the relay
8
+ * is closed. A write settles at once, as a relay with room does. `hangUp`
9
+ * runs every close listener, as a guest that hangs up does; closing the
10
+ * relay from the daemon's end runs none.
11
+ */
12
+ export function buildStubHarnessRelay() {
13
+ const lineListeners: ((line: string) => void)[] = [];
14
+ const closeListeners: (() => void)[] = [];
15
+ const written: unknown[] = [];
16
+ let closed = false;
17
+
18
+ const relay: HarnessRelay = {
19
+ onLine: (listener) => {
20
+ lineListeners.push(listener);
21
+ },
22
+ onClose: (listener) => {
23
+ closeListeners.push(listener);
24
+ },
25
+ writeLine: (line) => {
26
+ written.push(JSON.parse(line));
27
+
28
+ return Promise.resolve();
29
+ },
30
+ close: () => {
31
+ closed = true;
32
+ },
33
+ };
34
+
35
+ return {
36
+ relay,
37
+ written,
38
+ isClosed: () => closed,
39
+ sendLine: (value: Readonly<Record<string, unknown>>) => {
40
+ for (const listener of lineListeners) {
41
+ listener(JSON.stringify(value));
42
+ }
43
+ },
44
+ hangUp: () => {
45
+ for (const listener of closeListeners) {
46
+ listener();
47
+ }
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,60 @@
1
+ import type {
2
+ HeadlessRunEvents,
3
+ HeadlessRunRequest,
4
+ HeadlessRunner,
5
+ } from '../agents/agent-adapter';
6
+
7
+ interface StubHeadlessRun {
8
+ readonly request: HeadlessRunRequest;
9
+
10
+ // The events the daemon listens on, which the test calls to play the run.
11
+ readonly events: HeadlessRunEvents;
12
+ stopped: boolean;
13
+ }
14
+
15
+ interface StubHeadlessRunner {
16
+ readonly runner: HeadlessRunner;
17
+
18
+ // Every run the daemon started, in order, and whether it stopped it.
19
+ readonly runs: readonly StubHeadlessRun[];
20
+
21
+ // Resolves with the run at the index, counting from zero, once the
22
+ // daemon starts it.
23
+ readonly waitForRun: (index: number) => Promise<StubHeadlessRun>;
24
+ }
25
+
26
+ /**
27
+ * A headless runner for daemon tests that starts no process. Each run the
28
+ * daemon starts is recorded in `runs`, in order, and does nothing until
29
+ * the test plays it by calling the run's events: output, a finished turn,
30
+ * or a turn that needs the user. A stop marks the run stopped and plays
31
+ * nothing, as a killed turn reports nothing more.
32
+ */
33
+ export function buildStubHeadlessRunner(): StubHeadlessRunner {
34
+ const runs: StubHeadlessRun[] = [];
35
+ const started: PromiseWithResolvers<StubHeadlessRun>[] = [];
36
+
37
+ const getStarted = (index: number) => {
38
+ started[index] ??= Promise.withResolvers<StubHeadlessRun>();
39
+
40
+ return started[index];
41
+ };
42
+
43
+ return {
44
+ runner: (request, events) => {
45
+ const run: StubHeadlessRun = { request, events, stopped: false };
46
+
47
+ runs.push(run);
48
+
49
+ getStarted(runs.length - 1).resolve(run);
50
+
51
+ return {
52
+ stop: () => {
53
+ run.stopped = true;
54
+ },
55
+ };
56
+ },
57
+ runs,
58
+ waitForRun: (index) => getStarted(index).promise,
59
+ };
60
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The script of a stand-in `gh` whose repository listing waits for the
3
+ * test. `gh config …` prints `https`. Any other command touches
4
+ * `$HOME/gh-held` and waits while `$HOME/gh-hold` exists, then prints one
5
+ * repository, `me/dots`, in the JSON shape `gh repo list` prints.
6
+ */
7
+ export function buildStubHeldGH(): string {
8
+ return `#!/bin/sh
9
+ case "$1" in
10
+ config) echo https ;;
11
+ *)
12
+ if [ -f "$HOME/gh-hold" ]; then
13
+ touch "$HOME/gh-held"
14
+ while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
15
+ fi
16
+ echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
17
+ esac
18
+ `;
19
+ }
@@ -0,0 +1,36 @@
1
+ import type { TapClient } from '../daemon/daemon-context';
2
+ import type { InboxSource } from '../daemon/drain-inbox';
3
+ import { TapRegistry } from '../daemon/tap-registry';
4
+ import type { AgentSessionID } from '../shared/agent-session-id';
5
+ import type { SessionID } from '../shared/session-id';
6
+ import type { MessageRecord } from '../store/message-record';
7
+
8
+ /**
9
+ * An inbox source over a real, empty tap registry, standing in for the
10
+ * daemon's store. It holds every session under the given agent session id,
11
+ * and its pending-message read waits until the test answers it: `reading`
12
+ * settles once a read starts, `read` answers the read with the given
13
+ * messages whatever owner it asked for.
14
+ */
15
+ export function buildStubHeldInboxSource(agentSessionID: AgentSessionID) {
16
+ const pending = Promise.withResolvers<MessageRecord[]>();
17
+ const reading = Promise.withResolvers<void>();
18
+
19
+ const source: InboxSource = {
20
+ taps: new TapRegistry<TapClient>(),
21
+ findLinkedOwner: (sessionID: SessionID) => ({ atcID: sessionID, agentSessionID }),
22
+ collectPendingMessages: () => {
23
+ reading.resolve();
24
+
25
+ return pending.promise;
26
+ },
27
+ };
28
+
29
+ return {
30
+ source,
31
+ reading: reading.promise,
32
+ read: (records: readonly MessageRecord[]) => {
33
+ pending.resolve([...records]);
34
+ },
35
+ };
36
+ }