@zgeoff/atc 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +30 -17
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
4
3
|
import { DaemonClient } from '../client/daemon-client';
|
|
5
4
|
import { startDaemon } from '../daemon/daemon';
|
|
6
5
|
import type { DaemonHandle } from '../daemon/daemon';
|
|
7
6
|
import { openMCPAuth } from '../mcp/open-mcp-auth';
|
|
8
7
|
import { ReconnectingCaller } from '../mcp/reconnecting-caller';
|
|
9
8
|
import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
|
|
9
|
+
import { buildMockAgentAdapter } from './build-mock-agent-adapter';
|
|
10
10
|
import { setupTempDir } from './setup-temp-dir';
|
|
11
11
|
|
|
12
|
-
const SLEEP_ADAPTER: AgentAdapter = {
|
|
13
|
-
id: 'claude',
|
|
14
|
-
headlessRunner: null,
|
|
15
|
-
screenDetector: null,
|
|
16
|
-
takesMessages: false,
|
|
17
|
-
planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
|
|
18
|
-
normalizeHook: () => ({ kind: 'heartbeat' }),
|
|
19
|
-
loadName: () => Promise.resolve(null),
|
|
20
|
-
canResume: () => true,
|
|
21
|
-
buildResumeCommand: () => null,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
12
|
interface MCPHTTPSetupOptions {
|
|
25
|
-
// How long a rotated refresh token still answers with its successor.
|
|
26
|
-
readonly refreshReuseSeconds?: number;
|
|
27
|
-
|
|
28
13
|
// Further Host header values the server accepts.
|
|
29
14
|
readonly allowedHosts?: readonly string[];
|
|
30
15
|
}
|
|
@@ -38,11 +23,14 @@ interface MCPHTTPSetupOptions {
|
|
|
38
23
|
* `atc clients` and `atc grants` open it, and `addClient` adds a client
|
|
39
24
|
* through it and returns the client id. `restartDaemon` stops the daemon and
|
|
40
25
|
* starts a fresh one on the same socket and database, the way an operator
|
|
41
|
-
* restarts it, with the principals it is given; `countDaemonClients` reads
|
|
42
|
-
* daemon holds open. Hold the result with
|
|
26
|
+
* restarts it, with the principals it is given; `countDaemonClients` reads
|
|
27
|
+
* how many connections the current daemon holds open. Hold the result with
|
|
28
|
+
* `await using`.
|
|
43
29
|
*/
|
|
44
30
|
export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
45
|
-
|
|
31
|
+
await using stack = new AsyncDisposableStack();
|
|
32
|
+
|
|
33
|
+
const tmp = stack.use(setupTempDir('atc-mcp-http-'));
|
|
46
34
|
const socketPath = join(tmp.dir, 'daemon.sock');
|
|
47
35
|
const stateDir = join(tmp.dir, '.local', 'state', 'atc');
|
|
48
36
|
|
|
@@ -57,7 +45,7 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
57
45
|
socketPath,
|
|
58
46
|
reporterSocketPath: join(tmp.dir, 'reporter.sock'),
|
|
59
47
|
build: 'atc/test-build',
|
|
60
|
-
adapter:
|
|
48
|
+
adapter: buildMockAgentAdapter(),
|
|
61
49
|
dbPath: join(tmp.dir, 'state.db'),
|
|
62
50
|
statusPath: join(tmp.dir, 'status.json'),
|
|
63
51
|
principals,
|
|
@@ -65,10 +53,14 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
65
53
|
|
|
66
54
|
let daemon: DaemonHandle = await startTestDaemon();
|
|
67
55
|
|
|
56
|
+
stack.defer(() => daemon.stop());
|
|
57
|
+
|
|
68
58
|
const caller = new ReconnectingCaller(socketPath, 'atc/test-build', (path) =>
|
|
69
59
|
DaemonClient.open(path),
|
|
70
60
|
);
|
|
71
61
|
|
|
62
|
+
stack.defer(() => caller.stop());
|
|
63
|
+
|
|
72
64
|
const server = await startMCPHTTPServer({
|
|
73
65
|
caller,
|
|
74
66
|
build: 'atc/test-build',
|
|
@@ -83,13 +75,16 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
83
75
|
printRequest: (line) => {
|
|
84
76
|
requests.push(line);
|
|
85
77
|
},
|
|
86
|
-
...(options.refreshReuseSeconds === undefined
|
|
87
|
-
? {}
|
|
88
|
-
: { refreshReuseSeconds: options.refreshReuseSeconds }),
|
|
89
78
|
});
|
|
90
79
|
|
|
80
|
+
stack.defer(() => server.stop());
|
|
81
|
+
|
|
91
82
|
const store = await openMCPAuth({ dbPath, origin: null });
|
|
92
83
|
|
|
84
|
+
stack.defer(() => store.close());
|
|
85
|
+
|
|
86
|
+
const owned = stack.move();
|
|
87
|
+
|
|
93
88
|
return {
|
|
94
89
|
home: tmp.dir,
|
|
95
90
|
url: server.url,
|
|
@@ -114,13 +109,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
114
109
|
|
|
115
110
|
daemon = await startTestDaemon(principals);
|
|
116
111
|
},
|
|
117
|
-
|
|
118
|
-
await server.stop();
|
|
119
|
-
await store.close();
|
|
120
|
-
await caller.stop();
|
|
121
|
-
await daemon.stop();
|
|
122
|
-
|
|
123
|
-
tmp[Symbol.dispose]();
|
|
124
|
-
},
|
|
112
|
+
[Symbol.asyncDispose]: () => owned.disposeAsync(),
|
|
125
113
|
};
|
|
126
114
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { KEYS } from './keys';
|
|
2
|
+
import type { TUIHarness } from './start-tui-harness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spawns a Claude session named `name` through the client's spawn flow,
|
|
6
|
+
* from the home screen or the overlay: the first agent, the first
|
|
7
|
+
* directory, the name, and no first prompt. Resolves once the fake
|
|
8
|
+
* `claude` paints, with the capture holding what the client drew since the
|
|
9
|
+
* prompt step.
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
|
|
12
|
+
export async function spawnClaudeSession(tui: TUIHarness, name: string): Promise<void> {
|
|
13
|
+
tui.write('n');
|
|
14
|
+
|
|
15
|
+
await tui.waitFor('spawn: agent');
|
|
16
|
+
|
|
17
|
+
tui.write(KEYS.enter);
|
|
18
|
+
|
|
19
|
+
await tui.waitFor('spawn: directory');
|
|
20
|
+
|
|
21
|
+
tui.write(KEYS.enter);
|
|
22
|
+
|
|
23
|
+
await tui.waitFor('spawn: name');
|
|
24
|
+
|
|
25
|
+
tui.reset();
|
|
26
|
+
tui.write(`${name}${KEYS.enter}`);
|
|
27
|
+
|
|
28
|
+
await tui.waitFor('spawn: initial prompt');
|
|
29
|
+
|
|
30
|
+
tui.reset();
|
|
31
|
+
tui.write(KEYS.enter);
|
|
32
|
+
|
|
33
|
+
await tui.waitFor('FAKE_CLAUDE_UP');
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { KEYS } from './keys';
|
|
2
|
+
import type { TUIHarness } from './start-tui-harness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spawns a Grok session named `name` through the client's spawn flow, from
|
|
6
|
+
* the home screen or the overlay: Grok, the second agent, then the first
|
|
7
|
+
* directory, the name, and no first prompt. Resolves once the fake `grok`
|
|
8
|
+
* paints, with the capture holding what the client drew since the prompt
|
|
9
|
+
* step.
|
|
10
|
+
*/
|
|
11
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
|
|
12
|
+
export async function spawnGrokSession(tui: TUIHarness, name: string): Promise<void> {
|
|
13
|
+
tui.write('n');
|
|
14
|
+
|
|
15
|
+
await tui.waitFor('spawn: agent');
|
|
16
|
+
|
|
17
|
+
tui.reset();
|
|
18
|
+
tui.write(KEYS.down);
|
|
19
|
+
|
|
20
|
+
await tui.waitFor('\u001B[7mGrok');
|
|
21
|
+
|
|
22
|
+
tui.write(KEYS.enter);
|
|
23
|
+
|
|
24
|
+
await tui.waitFor('spawn: directory');
|
|
25
|
+
|
|
26
|
+
tui.write(KEYS.enter);
|
|
27
|
+
|
|
28
|
+
await tui.waitFor('spawn: name');
|
|
29
|
+
|
|
30
|
+
tui.reset();
|
|
31
|
+
tui.write(`${name}${KEYS.enter}`);
|
|
32
|
+
|
|
33
|
+
await tui.waitFor('spawn: initial prompt');
|
|
34
|
+
|
|
35
|
+
tui.reset();
|
|
36
|
+
tui.write(KEYS.enter);
|
|
37
|
+
|
|
38
|
+
await tui.waitFor('FAKE_GROK_UP');
|
|
39
|
+
}
|
|
@@ -20,7 +20,11 @@ export interface CutProxy {
|
|
|
20
20
|
|
|
21
21
|
// How many requests of the method the proxy has seen.
|
|
22
22
|
readonly countRequests: () => number;
|
|
23
|
+
|
|
24
|
+
// How many answers the proxy has swallowed in hold mode.
|
|
25
|
+
readonly countHeld: () => number;
|
|
23
26
|
readonly stop: () => void;
|
|
27
|
+
readonly [Symbol.dispose]: () => void;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
// One proxied connection: the target side once it is connected, what the
|
|
@@ -33,6 +37,9 @@ interface ProxyLink {
|
|
|
33
37
|
lineBuffer: string;
|
|
34
38
|
cutting: boolean;
|
|
35
39
|
held: boolean;
|
|
40
|
+
|
|
41
|
+
// Counts an answer the link swallows in hold mode.
|
|
42
|
+
readonly onHold: () => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -44,6 +51,7 @@ interface ProxyLink {
|
|
|
44
51
|
export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
45
52
|
let cutsLeft = options.cuts;
|
|
46
53
|
let requests = 0;
|
|
54
|
+
let held = 0;
|
|
47
55
|
|
|
48
56
|
const links = new Set<Socket<ProxyLink>>();
|
|
49
57
|
|
|
@@ -59,6 +67,9 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
|
59
67
|
lineBuffer: '',
|
|
60
68
|
cutting: false,
|
|
61
69
|
held: false,
|
|
70
|
+
onHold: () => {
|
|
71
|
+
held++;
|
|
72
|
+
},
|
|
62
73
|
};
|
|
63
74
|
|
|
64
75
|
links.add(client);
|
|
@@ -110,17 +121,21 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
|
110
121
|
},
|
|
111
122
|
});
|
|
112
123
|
|
|
124
|
+
const stop = () => {
|
|
125
|
+
for (const client of links) {
|
|
126
|
+
client.end();
|
|
127
|
+
client.data.upstream?.end();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
server.stop(true);
|
|
131
|
+
};
|
|
132
|
+
|
|
113
133
|
return {
|
|
114
134
|
port: server.port,
|
|
115
135
|
countRequests: () => requests,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
client.data.upstream?.end();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
server.stop(true);
|
|
123
|
-
},
|
|
136
|
+
countHeld: () => held,
|
|
137
|
+
stop,
|
|
138
|
+
[Symbol.dispose]: stop,
|
|
124
139
|
};
|
|
125
140
|
}
|
|
126
141
|
|
|
@@ -149,6 +164,8 @@ async function openUpstream(client: Socket<ProxyLink>, options: CutProxyOptions)
|
|
|
149
164
|
if (options.mode === 'hold') {
|
|
150
165
|
client.data.held = true;
|
|
151
166
|
|
|
167
|
+
client.data.onHold();
|
|
168
|
+
|
|
152
169
|
return;
|
|
153
170
|
}
|
|
154
171
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Subprocess } from 'bun';
|
|
4
|
+
import { DaemonClient } from '../client/daemon-client';
|
|
5
|
+
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
6
|
+
import { waitFor } from './wait-for';
|
|
7
|
+
|
|
8
|
+
interface DaemonProcessConfig {
|
|
9
|
+
// The command atc runs as, such as the source entry under bun or a
|
|
10
|
+
// compiled binary.
|
|
11
|
+
readonly command: readonly string[];
|
|
12
|
+
|
|
13
|
+
// HOME and XDG_RUNTIME_DIR of the daemon, so its config, state, and
|
|
14
|
+
// sockets all sit under this directory.
|
|
15
|
+
readonly home: string;
|
|
16
|
+
|
|
17
|
+
// Variables laid over the inherited environment and the default PATH;
|
|
18
|
+
// `undefined` removes one.
|
|
19
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
20
|
+
|
|
21
|
+
// Arguments after `atc daemon`.
|
|
22
|
+
readonly args?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One boot of the daemon: its process and the file its stderr goes to.
|
|
27
|
+
*/
|
|
28
|
+
interface DaemonBoot {
|
|
29
|
+
readonly proc: Subprocess;
|
|
30
|
+
readonly stderrPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Starts `atc daemon` as its own process on a home, with the test's
|
|
35
|
+
* environment, HOME and XDG_RUNTIME_DIR at the home, and a PATH of the system
|
|
36
|
+
* directories alone unless the config's variables set another. It returns at
|
|
37
|
+
* once; `openClient` waits up to 15 seconds for the socket and connects,
|
|
38
|
+
* sending no handshake, and fails at once with the daemon's stderr when the
|
|
39
|
+
* daemon exits before it listens. `restart` stops the daemon with the given
|
|
40
|
+
* signal, waits for it to exit, and starts another on the same home with the
|
|
41
|
+
* same config. `readStderr` reads what the current boot printed. Disposal
|
|
42
|
+
* closes every client it opened, kills the daemon and waits for it to exit,
|
|
43
|
+
* then kills the daemon the home's state directory records, which a restart
|
|
44
|
+
* the test asked atc for may have started; a second disposal does nothing.
|
|
45
|
+
*/
|
|
46
|
+
export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
47
|
+
const socketPath = join(config.home, 'atc-daemon.sock');
|
|
48
|
+
const stateDir = join(config.home, '.local', 'state', 'atc');
|
|
49
|
+
|
|
50
|
+
const clients = new Set<DaemonClient>();
|
|
51
|
+
|
|
52
|
+
let boots = 0;
|
|
53
|
+
let disposed = false;
|
|
54
|
+
|
|
55
|
+
const boot = (): DaemonBoot => {
|
|
56
|
+
boots++;
|
|
57
|
+
|
|
58
|
+
const stderrPath = join(config.home, `daemon-${boots}.stderr`);
|
|
59
|
+
|
|
60
|
+
const proc = Bun.spawn([...config.command, 'daemon', ...(config.args ?? [])], {
|
|
61
|
+
env: Object.fromEntries(
|
|
62
|
+
Object.entries({
|
|
63
|
+
...process.env,
|
|
64
|
+
HOME: config.home,
|
|
65
|
+
XDG_RUNTIME_DIR: config.home,
|
|
66
|
+
PATH: '/usr/sbin:/usr/bin:/bin',
|
|
67
|
+
...config.env,
|
|
68
|
+
}).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
69
|
+
),
|
|
70
|
+
stdout: 'ignore',
|
|
71
|
+
stderr: Bun.file(stderrPath),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { proc, stderrPath };
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let current = boot();
|
|
78
|
+
|
|
79
|
+
const readStderr = () => {
|
|
80
|
+
try {
|
|
81
|
+
return readFileSync(current.stderrPath, 'utf8');
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// A client that opens after the wait for it ended, or after disposal,
|
|
88
|
+
// closes at once, so nothing outlives the daemon it reached.
|
|
89
|
+
const openTrackedClient = async (isAbandoned: () => boolean) => {
|
|
90
|
+
const client = await DaemonClient.open(socketPath);
|
|
91
|
+
|
|
92
|
+
if (disposed || isAbandoned()) {
|
|
93
|
+
client.stop();
|
|
94
|
+
throw new Error('the wait for the daemon ended before this client opened');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clients.add(client);
|
|
98
|
+
|
|
99
|
+
return client;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const openClient = async (): Promise<DaemonClient> => {
|
|
103
|
+
const watched = current.proc;
|
|
104
|
+
let settled = false;
|
|
105
|
+
const isSettled = () => settled;
|
|
106
|
+
|
|
107
|
+
// A daemon that has exited either refused to start or handed its socket
|
|
108
|
+
// to a replacement, so one more connect tells the two apart.
|
|
109
|
+
const openAfterExit = async () => {
|
|
110
|
+
await watched.exited;
|
|
111
|
+
|
|
112
|
+
if (settled || disposed) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
return await openTrackedClient(isSettled);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`the daemon exited (${String(watched.exitCode ?? watched.signalCode)}) before it listened:\n${readStderr()}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Polls the socket while the daemon runs. Once the other wait settles or
|
|
126
|
+
// the daemon is disposed, each attempt resolves empty, which ends the
|
|
127
|
+
// polling.
|
|
128
|
+
const openWhenListening = () =>
|
|
129
|
+
waitFor(() => (settled || disposed ? null : openTrackedClient(isSettled)), {
|
|
130
|
+
timeoutMs: 15_000,
|
|
131
|
+
intervalMs: 50,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const hasExited = watched.exitCode !== null || watched.signalCode !== null;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const client = await (hasExited
|
|
138
|
+
? openAfterExit()
|
|
139
|
+
: Promise.race([openWhenListening(), openAfterExit()]));
|
|
140
|
+
|
|
141
|
+
if (client === null) {
|
|
142
|
+
throw new Error('the wait for the daemon settled without a client');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return client;
|
|
146
|
+
} finally {
|
|
147
|
+
settled = true;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
home: config.home,
|
|
153
|
+
socketPath,
|
|
154
|
+
reporterSocketPath: join(config.home, 'atc.sock'),
|
|
155
|
+
stateDir,
|
|
156
|
+
get proc(): Subprocess {
|
|
157
|
+
return current.proc;
|
|
158
|
+
},
|
|
159
|
+
readStderr,
|
|
160
|
+
openClient,
|
|
161
|
+
async restart(signal: NodeJS.Signals): Promise<void> {
|
|
162
|
+
current.proc.kill(signal);
|
|
163
|
+
|
|
164
|
+
await current.proc.exited;
|
|
165
|
+
|
|
166
|
+
current = boot();
|
|
167
|
+
},
|
|
168
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
169
|
+
if (disposed) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
disposed = true;
|
|
174
|
+
|
|
175
|
+
for (const client of clients) {
|
|
176
|
+
client.stop();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
current.proc.kill('SIGKILL');
|
|
180
|
+
|
|
181
|
+
await current.proc.exited;
|
|
182
|
+
|
|
183
|
+
const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
|
|
184
|
+
|
|
185
|
+
if (recorded !== null && recorded.pid !== current.proc.pid) {
|
|
186
|
+
try {
|
|
187
|
+
process.kill(recorded.pid, 'SIGKILL');
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -5,6 +5,18 @@ interface GitHTTPServer {
|
|
|
5
5
|
// Every Authorization header a request carried, in arrival order.
|
|
6
6
|
readonly authorizations: string[];
|
|
7
7
|
readonly stop: () => Promise<void>;
|
|
8
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface GitHTTPServerOptions {
|
|
12
|
+
// How long each authenticated request waits before it is served.
|
|
13
|
+
readonly delayMs?: number;
|
|
14
|
+
|
|
15
|
+
// Waits out the delay; a timer unless given.
|
|
16
|
+
readonly wait?: (ms: number) => Promise<void>;
|
|
17
|
+
|
|
18
|
+
// Runs, and is awaited, while each authenticated request is held.
|
|
19
|
+
readonly onRequest?: () => Promise<void> | void;
|
|
8
20
|
}
|
|
9
21
|
|
|
10
22
|
/**
|
|
@@ -13,13 +25,16 @@ interface GitHTTPServer {
|
|
|
13
25
|
* carries no basic auth, so a client only clones by authenticating. The
|
|
14
26
|
* backend runs with the environment it is given. With a delay, each
|
|
15
27
|
* authenticated request waits that long before it is served, which makes a
|
|
16
|
-
* clone through it slow.
|
|
28
|
+
* clone through it slow. An `onRequest` callback runs, and is awaited, while
|
|
29
|
+
* each authenticated request is held, so it sees the client that sent the
|
|
30
|
+
* request still running. Stop it with `stop`, or hold it with `await using`.
|
|
17
31
|
*/
|
|
18
32
|
export function startGitHTTPServer(
|
|
19
33
|
root: string,
|
|
20
34
|
env: Readonly<Record<string, string | undefined>>,
|
|
21
|
-
options:
|
|
35
|
+
options: GitHTTPServerOptions = {},
|
|
22
36
|
): GitHTTPServer {
|
|
37
|
+
const wait = options.wait ?? Bun.sleep;
|
|
23
38
|
const authorizations: string[] = [];
|
|
24
39
|
|
|
25
40
|
const server = Bun.serve({
|
|
@@ -38,9 +53,11 @@ export function startGitHTTPServer(
|
|
|
38
53
|
authorizations.push(authorization);
|
|
39
54
|
|
|
40
55
|
if (options.delayMs !== undefined) {
|
|
41
|
-
await
|
|
56
|
+
await wait(options.delayMs);
|
|
42
57
|
}
|
|
43
58
|
|
|
59
|
+
await options.onRequest?.();
|
|
60
|
+
|
|
44
61
|
const url = new URL(request.url);
|
|
45
62
|
|
|
46
63
|
const body = await request.arrayBuffer();
|
|
@@ -74,6 +91,7 @@ export function startGitHTTPServer(
|
|
|
74
91
|
url: `http://127.0.0.1:${server.port}/`,
|
|
75
92
|
authorizations,
|
|
76
93
|
stop: () => server.stop(true),
|
|
94
|
+
[Symbol.asyncDispose]: () => server.stop(true),
|
|
77
95
|
};
|
|
78
96
|
}
|
|
79
97
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
|
|
4
|
+
const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
|
|
5
|
+
|
|
6
|
+
interface MCPStdioOptions {
|
|
7
|
+
// The home the server runs with, as its `HOME` and `XDG_RUNTIME_DIR`.
|
|
8
|
+
readonly home: string;
|
|
9
|
+
|
|
10
|
+
// The session id the server sees itself running inside; absent means a
|
|
11
|
+
// server started outside any session.
|
|
12
|
+
readonly callerSessionID?: string;
|
|
13
|
+
|
|
14
|
+
// A command that stands in for `atc mcp`; absent means this checkout's
|
|
15
|
+
// `atc mcp`.
|
|
16
|
+
readonly command?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One tool call's result as the server returned it: `isError` as sent,
|
|
21
|
+
* `structured` as its `structuredContent`, and the text of its only content
|
|
22
|
+
* item.
|
|
23
|
+
*/
|
|
24
|
+
interface MCPToolResult {
|
|
25
|
+
readonly isError: unknown;
|
|
26
|
+
readonly text: string;
|
|
27
|
+
readonly structured: Readonly<Record<string, unknown>> | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `atc mcp` from this checkout, run as a subprocess over stdio, with an
|
|
32
|
+
* `initialize` already answered, so the daemon the server boots or joins is
|
|
33
|
+
* up. `sendRequest` sends one JSON-RPC request under the next id and
|
|
34
|
+
* resolves with its whole response. `sendToolCall` sends `tools/call` and
|
|
35
|
+
* resolves with the result, rejecting when the response holds no result, the
|
|
36
|
+
* result holds anything but one text item, or its structured content is not
|
|
37
|
+
* an object. `spawnSession` spawns through `atc_session_spawn` and resolves
|
|
38
|
+
* with the new session's id. A request still unanswered when the server's
|
|
39
|
+
* stdout ends rejects. When `initialize` fails, the server is stopped
|
|
40
|
+
* before the start rejects. Disposal stops the server and waits for it to exit,
|
|
41
|
+
* and a second disposal waits for the same exit; the daemon stays up for
|
|
42
|
+
* the home to stop.
|
|
43
|
+
*/
|
|
44
|
+
export async function startMCPStdio(options: MCPStdioOptions) {
|
|
45
|
+
const proc = Bun.spawn([...(options.command ?? [process.execPath, CLI_PATH, 'mcp'])], {
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
HOME: options.home,
|
|
49
|
+
XDG_RUNTIME_DIR: options.home,
|
|
50
|
+
PATH: '/usr/sbin:/usr/bin:/bin',
|
|
51
|
+
ATC_SESSION_ID: options.callerSessionID ?? '',
|
|
52
|
+
ATC_TAP_GRACE_MS: '0',
|
|
53
|
+
},
|
|
54
|
+
stdin: 'pipe',
|
|
55
|
+
stdout: 'pipe',
|
|
56
|
+
stderr: 'ignore',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const pending = new Map<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>();
|
|
60
|
+
|
|
61
|
+
void readResponses(proc.stdout, pending);
|
|
62
|
+
let nextID = 0;
|
|
63
|
+
|
|
64
|
+
const sendRequest = (
|
|
65
|
+
method: string,
|
|
66
|
+
params?: Readonly<Record<string, unknown>>,
|
|
67
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
68
|
+
nextID += 1;
|
|
69
|
+
|
|
70
|
+
const id = nextID;
|
|
71
|
+
const response = Promise.withResolvers<Readonly<Record<string, unknown>>>();
|
|
72
|
+
|
|
73
|
+
pending.set(id, response);
|
|
74
|
+
|
|
75
|
+
void proc.stdin.write(
|
|
76
|
+
`${JSON.stringify({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) })}\n`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
void proc.stdin.flush();
|
|
80
|
+
|
|
81
|
+
return response.promise;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sendToolCall = async (
|
|
85
|
+
name: string,
|
|
86
|
+
args: Readonly<Record<string, unknown>>,
|
|
87
|
+
): Promise<MCPToolResult> => {
|
|
88
|
+
const response = await sendRequest('tools/call', { name, arguments: args });
|
|
89
|
+
|
|
90
|
+
return toToolResult(response);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
let stopped = false;
|
|
94
|
+
|
|
95
|
+
const stopServer = async (): Promise<void> => {
|
|
96
|
+
if (!stopped) {
|
|
97
|
+
stopped = true;
|
|
98
|
+
void proc.stdin.end();
|
|
99
|
+
proc.kill();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await proc.exited;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// A failed initialize stops the server here, before the caller holds it.
|
|
106
|
+
await using stack = new AsyncDisposableStack();
|
|
107
|
+
|
|
108
|
+
stack.defer(stopServer);
|
|
109
|
+
|
|
110
|
+
const server = {
|
|
111
|
+
sendRequest,
|
|
112
|
+
sendToolCall,
|
|
113
|
+
async spawnSession(args: Readonly<Record<string, unknown>>): Promise<string> {
|
|
114
|
+
const spawned = await sendToolCall('atc_session_spawn', args);
|
|
115
|
+
|
|
116
|
+
const id = spawned.structured?.['id'];
|
|
117
|
+
|
|
118
|
+
if (typeof id !== 'string') {
|
|
119
|
+
throw new TypeError(`spawn returned no session id: ${spawned.text}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return id;
|
|
123
|
+
},
|
|
124
|
+
[Symbol.asyncDispose]: stopServer,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await sendRequest('initialize', {
|
|
128
|
+
protocolVersion: '2025-06-18',
|
|
129
|
+
capabilities: {},
|
|
130
|
+
clientInfo: { name: 'atc-test' },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
stack.move();
|
|
134
|
+
|
|
135
|
+
return server;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads the server's stdout as JSON-RPC lines and resolves the pending
|
|
140
|
+
* request each response's id matches. Once stdout ends, or holds a line that
|
|
141
|
+
* is not JSON, every request not yet resolved rejects.
|
|
142
|
+
*/
|
|
143
|
+
async function readResponses(
|
|
144
|
+
stdout: Readonly<ReadableStream<Uint8Array>>,
|
|
145
|
+
pending: ReadonlyMap<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const decoder = new TextDecoder('utf-8');
|
|
148
|
+
|
|
149
|
+
let buffer = '';
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
for await (const chunk of stdout) {
|
|
153
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
154
|
+
|
|
155
|
+
const lines = buffer.split('\n');
|
|
156
|
+
|
|
157
|
+
buffer = lines.pop() ?? '';
|
|
158
|
+
|
|
159
|
+
for (const line of lines.filter((candidate) => candidate.trim() !== '')) {
|
|
160
|
+
const parsed: unknown = JSON.parse(line);
|
|
161
|
+
const id = isRecord(parsed) ? parsed['id'] : undefined;
|
|
162
|
+
const waiting = typeof id === 'number' ? pending.get(id) : undefined;
|
|
163
|
+
|
|
164
|
+
if (isRecord(parsed) && waiting !== undefined) {
|
|
165
|
+
waiting.resolve(parsed);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// A line that is not JSON ends the reading; the requests still pending
|
|
171
|
+
// reject below.
|
|
172
|
+
} finally {
|
|
173
|
+
for (const [id, waiting] of pending) {
|
|
174
|
+
waiting.reject(new Error(`atc mcp stopped answering before request ${id}`));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toToolResult(response: Readonly<Record<string, unknown>>): MCPToolResult {
|
|
180
|
+
const result = response['result'];
|
|
181
|
+
const content = isRecord(result) ? result['content'] : undefined;
|
|
182
|
+
const item: unknown = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
|
|
183
|
+
const structured = isRecord(result) ? result['structuredContent'] : undefined;
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
!isRecord(result) ||
|
|
187
|
+
!isRecord(item) ||
|
|
188
|
+
typeof item['text'] !== 'string' ||
|
|
189
|
+
(structured !== undefined && !isRecord(structured))
|
|
190
|
+
) {
|
|
191
|
+
throw new TypeError(`tool call returned an unexpected result: ${JSON.stringify(response)}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { isError: result['isError'], text: item['text'], structured };
|
|
195
|
+
}
|