@zgeoff/atc 3.4.0 → 3.4.2
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 +2 -2
- package/src/agents/build-agent-adapters.ts +21 -3
- package/src/agents/claude-adapter.ts +4 -3
- package/src/agents/codex-adapter.ts +20 -0
- package/src/agents/gateway-adapter.ts +2 -2
- package/src/agents/plan-claude-line-input.ts +44 -0
- package/src/agents/render-sdk-message.ts +57 -0
- package/src/agents/start-claude-headless-run.ts +2 -57
- package/src/agents/with-claude-config-lock.ts +28 -15
- package/src/client/boot-daemon.ts +75 -25
- package/src/client/resolve-workspace-root.ts +10 -5
- package/src/daemon/session-runtime.ts +0 -4
- package/src/gateway.ts +2 -179
- package/src/mcp/mcp-tools.ts +1 -1
- package/src/mcp-http-server.ts +13 -10
- package/src/run-gateway-cli.ts +327 -0
- package/src/run-gateway.ts +49 -18
- package/src/sources/github/run-gh.ts +9 -0
- package/src/test-utils/build-mock-execution-target.ts +25 -0
- package/src/test-utils/build-mock-imp-session-request.ts +53 -0
- package/src/test-utils/build-mock-message-record.ts +7 -3
- package/src/test-utils/build-mock-stored-event.ts +20 -0
- package/src/test-utils/build-mock-stored-row.ts +23 -0
- package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
- package/src/test-utils/build-stub-held-gh.ts +5 -7
- package/src/test-utils/build-stub-held-zoxide.ts +4 -6
- package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
- package/src/test-utils/build-stub-recording-filter.ts +9 -0
- package/src/test-utils/build-stub-recording-git.ts +8 -0
- package/src/test-utils/create-git-fixture.ts +5 -8
- package/src/test-utils/create-stub-claude.ts +3 -1
- package/src/test-utils/create-stub-echo-claude.ts +21 -0
- package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
- package/src/test-utils/create-stub-recording-codex.ts +20 -0
- package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
- package/src/test-utils/create-stub-systemd.ts +10 -4
- package/src/test-utils/get-only-imp-name.ts +2 -2
- package/src/test-utils/register-test-cleanup.ts +63 -0
- package/src/test-utils/run-mcp-authorization.ts +7 -4
- package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
- package/src/test-utils/setup-mcp-home.ts +24 -13
- package/src/test-utils/setup-mcp-http.ts +15 -7
- package/src/test-utils/setup-temp-dir.ts +7 -6
- package/src/test-utils/start-cut-proxy.ts +6 -2
- package/src/test-utils/start-daemon-process.ts +25 -24
- package/src/test-utils/start-git-http-server.ts +9 -3
- package/src/test-utils/start-mcp-stdio.ts +17 -22
- package/src/test-utils/start-stub-answering-listener.ts +65 -0
- package/src/test-utils/start-stub-closing-listener.ts +31 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
- package/src/test-utils/start-stub-impd-info.ts +10 -4
- package/src/test-utils/start-stub-impd.ts +8 -3
- package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
- package/src/test-utils/start-stub-mcp-server.ts +7 -2
- package/src/test-utils/start-stub-recording-listener.ts +47 -0
- package/src/test-utils/start-stub-refusing-listener.ts +9 -4
- package/src/test-utils/start-stub-reporter-socket.ts +6 -4
- package/src/test-utils/start-stub-restore-daemon.ts +5 -4
- package/src/test-utils/start-stub-session-bridge.ts +6 -4
- package/src/test-utils/start-stub-silent-server.ts +10 -5
- package/src/test-utils/start-stub-source-daemon.ts +6 -4
- package/src/test-utils/start-stub-stalled-client.ts +61 -0
- package/src/test-utils/start-stub-stalled-listener.ts +13 -9
- package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
- package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
- package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
- package/src/test-utils/start-test-daemon.ts +20 -6
- package/src/test-utils/start-tui-harness.ts +15 -8
- package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
- /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createStubBin } from './create-stub-bin';
|
|
3
|
+
|
|
4
|
+
interface StubSharedServerCodexConfig {
|
|
5
|
+
// The command atc runs as; the stub reports each hook through its
|
|
6
|
+
// `hook-report --agent codex`, as the hook entries atc prints for Codex do.
|
|
7
|
+
readonly atc: readonly string[];
|
|
8
|
+
|
|
9
|
+
// The composer script the stub finishes in.
|
|
10
|
+
readonly composer: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a stand-in for a Codex CLI that shares one background server
|
|
15
|
+
* between its terminals, as `fake-codex` under the directory, and returns
|
|
16
|
+
* its path. Without `--no-daemon`, the first start records its own
|
|
17
|
+
* `ATC_SESSION_ID` and `ATC_SOCKET` in `codex-shared-server.env` under the
|
|
18
|
+
* directory, as the server it starts keeps its environment, and every start
|
|
19
|
+
* runs its hooks with the recorded pair, as Codex runs hooks inside that
|
|
20
|
+
* server. With `--no-daemon`, a start runs its hooks with its own pair. The
|
|
21
|
+
* stub prints its arguments, reports a `SessionStart` for its thread, which
|
|
22
|
+
* is `fake-thread-<its own ATC_SESSION_ID>` or the id after `resume`, then
|
|
23
|
+
* a `Stop` whose last message is `done <thread>`, then runs the composer.
|
|
24
|
+
*/
|
|
25
|
+
export function createStubSharedServerCodex(
|
|
26
|
+
dir: string,
|
|
27
|
+
config: StubSharedServerCodexConfig,
|
|
28
|
+
): string {
|
|
29
|
+
const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent codex`;
|
|
30
|
+
const server = join(dir, 'codex-shared-server.env');
|
|
31
|
+
|
|
32
|
+
return createStubBin(
|
|
33
|
+
dir,
|
|
34
|
+
'fake-codex',
|
|
35
|
+
`#!/usr/bin/env bash
|
|
36
|
+
echo "FAKE_CODEX_UP args: $*"
|
|
37
|
+
thread="fake-thread-$ATC_SESSION_ID"
|
|
38
|
+
source=startup
|
|
39
|
+
prev=''
|
|
40
|
+
for arg in "$@"; do
|
|
41
|
+
if [ "$prev" = resume ]; then thread="$arg"; source=resume; fi
|
|
42
|
+
prev="$arg"
|
|
43
|
+
done
|
|
44
|
+
case " $* " in
|
|
45
|
+
*' --no-daemon '*) ;;
|
|
46
|
+
*)
|
|
47
|
+
(set -o noclobber; printf 'ATC_SESSION_ID=%q\\nATC_SOCKET=%q\\n' "$ATC_SESSION_ID" "$ATC_SOCKET" > '${server}') 2>/dev/null
|
|
48
|
+
. '${server}'
|
|
49
|
+
;;
|
|
50
|
+
esac
|
|
51
|
+
printf '{"hook_event_name":"SessionStart","session_id":"%s","source":"%s"}' "$thread" "$source" | ${report}
|
|
52
|
+
printf '{"hook_event_name":"Stop","session_id":"%s","last_assistant_message":"done %s"}' "$thread" "$thread" | ${report}
|
|
53
|
+
exec "${process.execPath}" "${config.composer}"
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
4
5
|
|
|
5
6
|
interface StubSystemd {
|
|
6
7
|
// The directory that holds the stand-in `systemctl` and `systemd-run`, to put
|
|
@@ -34,10 +35,17 @@ interface StubSystemd {
|
|
|
34
35
|
* `atcCommand daemon` in the background. `systemd-run` runs the command after
|
|
35
36
|
* `--` in the background with only the `--setenv` variables, as a transient
|
|
36
37
|
* unit sees them, and appends its output to the `StandardOutput=append:`
|
|
37
|
-
* file. Every call is logged.
|
|
38
|
+
* file. Every call is logged. The stand-ins' directory is removed once the
|
|
39
|
+
* current test finishes, so it must run inside a test; disposal removes it
|
|
40
|
+
* sooner, and a second removal does nothing.
|
|
38
41
|
*/
|
|
39
42
|
export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
|
|
40
43
|
const root = mkdtempSync(join(tmpdir(), 'atc-stub-systemd-'));
|
|
44
|
+
|
|
45
|
+
const remove = registerTestCleanup(() => {
|
|
46
|
+
rmSync(root, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
41
49
|
const binDir = join(root, 'bin');
|
|
42
50
|
const procRoot = join(root, 'proc');
|
|
43
51
|
const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
|
|
@@ -118,8 +126,6 @@ exit 0
|
|
|
118
126
|
`0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
|
|
119
127
|
);
|
|
120
128
|
},
|
|
121
|
-
[Symbol.dispose]:
|
|
122
|
-
rmSync(root, { recursive: true, force: true });
|
|
123
|
-
},
|
|
129
|
+
[Symbol.dispose]: remove,
|
|
124
130
|
};
|
|
125
131
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { createStubImpPort } from './create-stub-imp-port';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Returns the name of the one imp a stub imp port holds, and throws when
|
|
@@ -6,7 +6,7 @@ import type { buildStubImpPort } from './build-stub-imp-port';
|
|
|
6
6
|
* reads another's.
|
|
7
7
|
*/
|
|
8
8
|
export function getOnlyImpName(
|
|
9
|
-
port: Readonly<Pick<ReturnType<typeof
|
|
9
|
+
port: Readonly<Pick<ReturnType<typeof createStubImpPort>, 'collectImpNames'>>,
|
|
10
10
|
): string {
|
|
11
11
|
const names = port.collectImpNames();
|
|
12
12
|
const [name] = names;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { onTestFinished } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// The releases the current test registered, in registration order; null
|
|
4
|
+
// before its first registration and once its hook has started.
|
|
5
|
+
let pending: (() => unknown)[] | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers the release of a resource a test just acquired, to run once the
|
|
9
|
+
* current test finishes, and returns that release for the resource's own
|
|
10
|
+
* stop and dispose members. Every release a test registers runs from one
|
|
11
|
+
* hook, last registered first, as `using` declarations release, so a
|
|
12
|
+
* directory made before a process that lives in it is removed only after
|
|
13
|
+
* that process stops. A release that throws stops none of the others: the
|
|
14
|
+
* hook rethrows once every release has run, as an `AggregateError` when
|
|
15
|
+
* more than one threw. The returned release runs its callback once: every
|
|
16
|
+
* later call, from the hook or from the test, returns what the first call
|
|
17
|
+
* returned. Bun runs the hook after every `afterEach`, so a release works
|
|
18
|
+
* from the paths and handles it captured. Throws outside a test, where
|
|
19
|
+
* nothing would run the hook.
|
|
20
|
+
*/
|
|
21
|
+
export function registerTestCleanup<T>(release: () => T): () => T {
|
|
22
|
+
let released: { readonly value: T } | null = null;
|
|
23
|
+
|
|
24
|
+
const releaseOnce = (): T => {
|
|
25
|
+
released ??= { value: release() };
|
|
26
|
+
|
|
27
|
+
return released.value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (pending === null) {
|
|
31
|
+
onTestFinished(releasePending);
|
|
32
|
+
|
|
33
|
+
pending = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pending.push(releaseOnce);
|
|
37
|
+
|
|
38
|
+
return releaseOnce;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function releasePending(): Promise<void> {
|
|
42
|
+
const releases = pending ?? [];
|
|
43
|
+
|
|
44
|
+
pending = null;
|
|
45
|
+
|
|
46
|
+
const errors: unknown[] = [];
|
|
47
|
+
|
|
48
|
+
for (const release of releases.toReversed()) {
|
|
49
|
+
try {
|
|
50
|
+
await release();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
errors.push(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (errors.length === 1) {
|
|
57
|
+
throw errors[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (errors.length > 1) {
|
|
61
|
+
throw new AggregateError(errors, 'more than one test cleanup failed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -18,6 +18,9 @@ interface MCPAuthorizationRequest {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
interface MCPAuthorization {
|
|
21
|
+
// The consent page the approval code sent the browser to.
|
|
22
|
+
readonly consent: URL;
|
|
23
|
+
|
|
21
24
|
// Where the consent page sent the browser back to.
|
|
22
25
|
readonly callback: URL;
|
|
23
26
|
readonly code: string;
|
|
@@ -29,9 +32,9 @@ interface MCPAuthorization {
|
|
|
29
32
|
* browser and the operator do: opens the authorization request with PKCE,
|
|
30
33
|
* types the approval code the server printed into the login page, and ticks
|
|
31
34
|
* the given scopes on the consent page. Each request starts with no cookies,
|
|
32
|
-
* as a fresh browser would. Returns
|
|
33
|
-
* request needs, and throws when any step's
|
|
34
|
-
* step depends on.
|
|
35
|
+
* as a fresh browser would. Returns the consent page it passed, where the
|
|
36
|
+
* browser landed, and what a token request needs, and throws when any step's
|
|
37
|
+
* response lacks the value the next step depends on.
|
|
35
38
|
*/
|
|
36
39
|
export async function runMCPAuthorization(
|
|
37
40
|
server: MCPAuthorizationServer,
|
|
@@ -114,5 +117,5 @@ export async function runMCPAuthorization(
|
|
|
114
117
|
throw new Error(`the consent redirect holds no authorization code: ${location}`);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
return { callback, code, verifier };
|
|
120
|
+
return { consent, callback, code, verifier };
|
|
118
121
|
}
|
|
@@ -20,10 +20,10 @@ function main() {
|
|
|
20
20
|
const stateDir = process.argv.at(3);
|
|
21
21
|
|
|
22
22
|
if (socketPath === undefined || stateDir === undefined) {
|
|
23
|
-
throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
|
|
23
|
+
throw new Error('usage: run-stub-legacy-daemon.ts <socket path> <state dir>');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
|
|
26
|
+
startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
|
|
27
27
|
|
|
28
28
|
const session = Bun.spawn(['sleep', '60']);
|
|
29
29
|
|
|
@@ -5,6 +5,7 @@ import { stopDaemonProcess } from '../stop-daemon-process';
|
|
|
5
5
|
import { buildStubMCPClaude } from './build-stub-mcp-claude';
|
|
6
6
|
import { buildStubMCPGrok } from './build-stub-mcp-grok';
|
|
7
7
|
import { createStubBin } from './create-stub-bin';
|
|
8
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
8
9
|
import { setupTempDir } from './setup-temp-dir';
|
|
9
10
|
import { waitFor } from './wait-for';
|
|
10
11
|
|
|
@@ -17,13 +18,33 @@ import { waitFor } from './wait-for';
|
|
|
17
18
|
* home. Disposal stops the daemon, when one wrote its pid file here, and
|
|
18
19
|
* waits for it to exit; then it kills the daemon's process group and every
|
|
19
20
|
* group `stub-pids` records, such as a reporter a stopped session left
|
|
20
|
-
* behind, waits until each group is empty, and removes the home.
|
|
21
|
-
*
|
|
21
|
+
* behind, waits until each group is empty, and removes the home. That
|
|
22
|
+
* disposal runs once the current test finishes, so it must run inside a
|
|
23
|
+
* test; disposing sooner runs it then, and a second disposal does nothing.
|
|
22
24
|
*/
|
|
23
25
|
export function setupMCPHome() {
|
|
24
26
|
const tmp = setupTempDir('atc-mcp-');
|
|
27
|
+
|
|
28
|
+
// Registered after the home, so it releases first: the daemon and the
|
|
29
|
+
// stand-ins stop before the home that records them is removed.
|
|
30
|
+
const stack = new AsyncDisposableStack();
|
|
31
|
+
|
|
32
|
+
const dispose = registerTestCleanup(() => stack.disposeAsync());
|
|
33
|
+
|
|
34
|
+
stack.use(tmp);
|
|
35
|
+
|
|
25
36
|
const home = tmp.dir;
|
|
26
37
|
|
|
38
|
+
stack.defer(async () => {
|
|
39
|
+
const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
|
|
40
|
+
|
|
41
|
+
if (pid !== null) {
|
|
42
|
+
await stopDaemonProcess(pid);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
|
|
46
|
+
});
|
|
47
|
+
|
|
27
48
|
mkdirSync(join(home, '.config', 'atc'), { recursive: true });
|
|
28
49
|
mkdirSync(join(home, '.local', 'state', 'atc'), { recursive: true });
|
|
29
50
|
|
|
@@ -39,17 +60,7 @@ export function setupMCPHome() {
|
|
|
39
60
|
home,
|
|
40
61
|
claudeBin,
|
|
41
62
|
grokBin,
|
|
42
|
-
|
|
43
|
-
const pid = findPidFilePID(join(home, 'atc-daemon.pid'));
|
|
44
|
-
|
|
45
|
-
if (pid !== null) {
|
|
46
|
-
await stopDaemonProcess(pid);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
await killProcessGroups([...(pid === null ? [] : [pid]), ...readStubPIDs(home)]);
|
|
50
|
-
|
|
51
|
-
tmp[Symbol.dispose]();
|
|
52
|
-
},
|
|
63
|
+
[Symbol.asyncDispose]: dispose,
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
|
@@ -7,6 +7,7 @@ import { openMCPAuth } from '../mcp/open-mcp-auth';
|
|
|
7
7
|
import { ReconnectingCaller } from '../mcp/reconnecting-caller';
|
|
8
8
|
import { startMCPHTTPServer } from '../mcp/start-mcp-http-server';
|
|
9
9
|
import { buildMockAgentAdapter } from './build-mock-agent-adapter';
|
|
10
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
10
11
|
import { setupTempDir } from './setup-temp-dir';
|
|
11
12
|
|
|
12
13
|
interface MCPHTTPSetupOptions {
|
|
@@ -24,13 +25,22 @@ interface MCPHTTPSetupOptions {
|
|
|
24
25
|
* through it and returns the client id. `restartDaemon` stops the daemon and
|
|
25
26
|
* starts a fresh one on the same socket and database, the way an operator
|
|
26
27
|
* restarts it, with the principals it is given; `countDaemonClients` reads
|
|
27
|
-
* how many connections the current daemon holds open.
|
|
28
|
-
*
|
|
28
|
+
* how many connections the current daemon holds open. Everything it starts
|
|
29
|
+
* stops, and the directory is removed, once the current test finishes, so it
|
|
30
|
+
* must run inside a test; disposal does so sooner, and a second disposal
|
|
31
|
+
* does nothing.
|
|
29
32
|
*/
|
|
30
33
|
export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
31
|
-
|
|
34
|
+
const tmp = setupTempDir('atc-mcp-http-');
|
|
35
|
+
|
|
36
|
+
// Registered after the directory, so it releases first: each part stops
|
|
37
|
+
// before the one it depends on, and the directory goes last.
|
|
38
|
+
const stack = new AsyncDisposableStack();
|
|
39
|
+
|
|
40
|
+
const dispose = registerTestCleanup(() => stack.disposeAsync());
|
|
41
|
+
|
|
42
|
+
stack.use(tmp);
|
|
32
43
|
|
|
33
|
-
const tmp = stack.use(setupTempDir('atc-mcp-http-'));
|
|
34
44
|
const socketPath = join(tmp.dir, 'daemon.sock');
|
|
35
45
|
const stateDir = join(tmp.dir, '.local', 'state', 'atc');
|
|
36
46
|
|
|
@@ -83,8 +93,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
83
93
|
|
|
84
94
|
stack.defer(() => store.close());
|
|
85
95
|
|
|
86
|
-
const owned = stack.move();
|
|
87
|
-
|
|
88
96
|
return {
|
|
89
97
|
home: tmp.dir,
|
|
90
98
|
url: server.url,
|
|
@@ -109,6 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
109
117
|
|
|
110
118
|
daemon = await startTestDaemon(principals);
|
|
111
119
|
},
|
|
112
|
-
[Symbol.asyncDispose]:
|
|
120
|
+
[Symbol.asyncDispose]: dispose,
|
|
113
121
|
};
|
|
114
122
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
4
5
|
|
|
5
6
|
interface TempDir {
|
|
6
7
|
readonly dir: string;
|
|
@@ -9,17 +10,17 @@ interface TempDir {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* Creates a
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Creates a directory under the system temp root, named by the prefix, and
|
|
14
|
+
* registers its removal to run once the current test finishes, so it must
|
|
15
|
+
* run inside a test. Disposal removes the tree sooner; whichever comes
|
|
16
|
+
* second does nothing.
|
|
16
17
|
*/
|
|
17
18
|
export function setupTempDir(prefix: string): TempDir {
|
|
18
19
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
19
20
|
|
|
20
|
-
const remove = () => {
|
|
21
|
+
const remove = registerTestCleanup(() => {
|
|
21
22
|
rmSync(dir, { recursive: true, force: true });
|
|
22
|
-
};
|
|
23
|
+
});
|
|
23
24
|
|
|
24
25
|
return {
|
|
25
26
|
dir,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Socket } from 'bun';
|
|
2
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
2
3
|
|
|
3
4
|
interface CutProxyOptions {
|
|
4
5
|
// The address the proxy forwards to.
|
|
@@ -47,6 +48,9 @@ interface ProxyLink {
|
|
|
47
48
|
* connection byte for byte, except that for the first `cuts` requests of
|
|
48
49
|
* the method it loses the target's next answer after the request reached
|
|
49
50
|
* the target: the request runs, and the client never sees its response.
|
|
51
|
+
* It stops, ending every link, once the current test finishes, so it must
|
|
52
|
+
* run inside a test; `stop` or disposal stops it sooner, and a second stop
|
|
53
|
+
* does nothing.
|
|
50
54
|
*/
|
|
51
55
|
export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
52
56
|
let cutsLeft = options.cuts;
|
|
@@ -121,14 +125,14 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
|
121
125
|
},
|
|
122
126
|
});
|
|
123
127
|
|
|
124
|
-
const stop = () => {
|
|
128
|
+
const stop = registerTestCleanup(() => {
|
|
125
129
|
for (const client of links) {
|
|
126
130
|
client.end();
|
|
127
131
|
client.data.upstream?.end();
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
server.stop(true);
|
|
131
|
-
};
|
|
135
|
+
});
|
|
132
136
|
|
|
133
137
|
return {
|
|
134
138
|
port: server.port,
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import type { Subprocess } from 'bun';
|
|
4
4
|
import { DaemonClient } from '../client/daemon-client';
|
|
5
5
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
6
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
6
7
|
import { waitFor } from './wait-for';
|
|
7
8
|
|
|
8
9
|
interface DaemonProcessConfig {
|
|
@@ -41,7 +42,9 @@ interface DaemonBoot {
|
|
|
41
42
|
* same config. `readStderr` reads what the current boot printed. Disposal
|
|
42
43
|
* closes every client it opened, kills the daemon and waits for it to exit,
|
|
43
44
|
* then kills the daemon the home's state directory records, which a restart
|
|
44
|
-
* the test asked atc for may have started
|
|
45
|
+
* the test asked atc for may have started. That disposal runs once the
|
|
46
|
+
* current test finishes, so it must run inside a test; disposing sooner
|
|
47
|
+
* runs it then, and a second disposal does nothing.
|
|
45
48
|
*/
|
|
46
49
|
export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
47
50
|
const socketPath = join(config.home, 'atc-daemon.sock');
|
|
@@ -76,6 +79,26 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
76
79
|
|
|
77
80
|
let current = boot();
|
|
78
81
|
|
|
82
|
+
const dispose = registerTestCleanup(async () => {
|
|
83
|
+
disposed = true;
|
|
84
|
+
|
|
85
|
+
for (const client of clients) {
|
|
86
|
+
client.stop();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
current.proc.kill('SIGKILL');
|
|
90
|
+
|
|
91
|
+
await current.proc.exited;
|
|
92
|
+
|
|
93
|
+
const recorded = findDaemonRecord(join(stateDir, 'daemon.json'));
|
|
94
|
+
|
|
95
|
+
if (recorded !== null && recorded.pid !== current.proc.pid) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(recorded.pid, 'SIGKILL');
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
79
102
|
const readStderr = () => {
|
|
80
103
|
try {
|
|
81
104
|
return readFileSync(current.stderrPath, 'utf8');
|
|
@@ -165,28 +188,6 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
165
188
|
|
|
166
189
|
current = boot();
|
|
167
190
|
},
|
|
168
|
-
|
|
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
|
+
[Symbol.asyncDispose]: dispose,
|
|
191
192
|
};
|
|
192
193
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
2
|
+
|
|
1
3
|
interface GitHTTPServer {
|
|
2
4
|
// The base URL a repository under the root is served at, ending in `/`.
|
|
3
5
|
readonly url: string;
|
|
@@ -27,7 +29,9 @@ interface GitHTTPServerOptions {
|
|
|
27
29
|
* authenticated request waits that long before it is served, which makes a
|
|
28
30
|
* clone through it slow. An `onRequest` callback runs, and is awaited, while
|
|
29
31
|
* each authenticated request is held, so it sees the client that sent the
|
|
30
|
-
* request still running.
|
|
32
|
+
* request still running. It stops once the current test finishes, so it
|
|
33
|
+
* must run inside a test; `stop` or disposal stops it sooner, and a second
|
|
34
|
+
* stop does nothing.
|
|
31
35
|
*/
|
|
32
36
|
export function startGitHTTPServer(
|
|
33
37
|
root: string,
|
|
@@ -87,11 +91,13 @@ export function startGitHTTPServer(
|
|
|
87
91
|
},
|
|
88
92
|
});
|
|
89
93
|
|
|
94
|
+
const stop = registerTestCleanup(() => server.stop(true));
|
|
95
|
+
|
|
90
96
|
return {
|
|
91
97
|
url: `http://127.0.0.1:${server.port}/`,
|
|
92
98
|
authorizations,
|
|
93
|
-
stop
|
|
94
|
-
[Symbol.asyncDispose]:
|
|
99
|
+
stop,
|
|
100
|
+
[Symbol.asyncDispose]: stop,
|
|
95
101
|
};
|
|
96
102
|
}
|
|
97
103
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { isRecord } from '../shared/report';
|
|
3
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
3
4
|
|
|
4
5
|
const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
|
|
5
6
|
|
|
@@ -37,9 +38,10 @@ interface MCPToolResult {
|
|
|
37
38
|
* an object. `spawnSession` spawns through `atc_session_spawn` and resolves
|
|
38
39
|
* with the new session's id. A request still unanswered when the server's
|
|
39
40
|
* stdout ends rejects. When `initialize` fails, the server is stopped
|
|
40
|
-
* before the start rejects.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* before the start rejects. The server stops once the current test
|
|
42
|
+
* finishes, so it must run inside a test; disposal stops it sooner and waits
|
|
43
|
+
* for it to exit, and a second disposal waits for the same exit. The daemon
|
|
44
|
+
* stays up for the home to stop.
|
|
43
45
|
*/
|
|
44
46
|
export async function startMCPStdio(options: MCPStdioOptions) {
|
|
45
47
|
const proc = Bun.spawn([...(options.command ?? [process.execPath, CLI_PATH, 'mcp'])], {
|
|
@@ -56,6 +58,13 @@ export async function startMCPStdio(options: MCPStdioOptions) {
|
|
|
56
58
|
stderr: 'ignore',
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
const stopServer = registerTestCleanup(async (): Promise<void> => {
|
|
62
|
+
void proc.stdin.end();
|
|
63
|
+
proc.kill();
|
|
64
|
+
|
|
65
|
+
await proc.exited;
|
|
66
|
+
});
|
|
67
|
+
|
|
59
68
|
const pending = new Map<number, PromiseWithResolvers<Readonly<Record<string, unknown>>>>();
|
|
60
69
|
|
|
61
70
|
void readResponses(proc.stdout, pending);
|
|
@@ -90,23 +99,6 @@ export async function startMCPStdio(options: MCPStdioOptions) {
|
|
|
90
99
|
return toToolResult(response);
|
|
91
100
|
};
|
|
92
101
|
|
|
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
102
|
const server = {
|
|
111
103
|
sendRequest,
|
|
112
104
|
sendToolCall,
|
|
@@ -124,13 +116,16 @@ export async function startMCPStdio(options: MCPStdioOptions) {
|
|
|
124
116
|
[Symbol.asyncDispose]: stopServer,
|
|
125
117
|
};
|
|
126
118
|
|
|
119
|
+
// A failed initialize stops the server here, before the start rejects.
|
|
127
120
|
await sendRequest('initialize', {
|
|
128
121
|
protocolVersion: '2025-06-18',
|
|
129
122
|
capabilities: {},
|
|
130
123
|
clientInfo: { name: 'atc-test' },
|
|
131
|
-
})
|
|
124
|
+
}).catch(async (error: unknown) => {
|
|
125
|
+
await stopServer();
|
|
132
126
|
|
|
133
|
-
|
|
127
|
+
throw error;
|
|
128
|
+
});
|
|
134
129
|
|
|
135
130
|
return server;
|
|
136
131
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createServer } from 'node:net';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
4
|
+
|
|
5
|
+
interface StubAnsweringListener {
|
|
6
|
+
// The server's side of each connection it accepted, in order.
|
|
7
|
+
readonly peers: readonly Socket[];
|
|
8
|
+
|
|
9
|
+
// The first read each connection sent, decoded, in arrival order.
|
|
10
|
+
readonly lines: readonly string[];
|
|
11
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A stand-in for a daemon socket that answers a handshake: it listens on
|
|
16
|
+
* the unix socket path, records each connection it accepts and the first
|
|
17
|
+
* read each one sends, answers that read with the line `answer`, and
|
|
18
|
+
* leaves every later byte unread on the connection. Resolves once it
|
|
19
|
+
* listens. Once the current test finishes, it destroys every connection it
|
|
20
|
+
* accepted and closes, so it must run inside a test; disposal does so
|
|
21
|
+
* sooner and resolves once the server has closed, and a second stop does
|
|
22
|
+
* nothing.
|
|
23
|
+
*/
|
|
24
|
+
export async function startStubAnsweringListener(path: string): Promise<StubAnsweringListener> {
|
|
25
|
+
const peers: Socket[] = [];
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
|
|
28
|
+
const server = createServer((peer) => {
|
|
29
|
+
peers.push(peer);
|
|
30
|
+
|
|
31
|
+
peer.once('data', (data) => {
|
|
32
|
+
peer.pause();
|
|
33
|
+
lines.push(data.toString());
|
|
34
|
+
peer.write('answer\n');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const listening = Promise.withResolvers<void>();
|
|
39
|
+
|
|
40
|
+
server.listen(path, () => {
|
|
41
|
+
listening.resolve();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await listening.promise;
|
|
45
|
+
|
|
46
|
+
const stop = registerTestCleanup(async () => {
|
|
47
|
+
for (const peer of peers) {
|
|
48
|
+
peer.destroy();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const closed = Promise.withResolvers<void>();
|
|
52
|
+
|
|
53
|
+
server.close(() => {
|
|
54
|
+
closed.resolve();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await closed.promise;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
peers,
|
|
62
|
+
lines,
|
|
63
|
+
[Symbol.asyncDispose]: stop,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { registerTestCleanup } from './register-test-cleanup';
|
|
2
|
+
|
|
3
|
+
interface StubClosingListener {
|
|
4
|
+
readonly [Symbol.dispose]: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A stand-in for a daemon that hangs up: it listens on the unix socket path
|
|
9
|
+
* and ends every connection as soon as it opens, reading nothing. It stops
|
|
10
|
+
* once the current test finishes, so it must run inside a test; disposal
|
|
11
|
+
* stops it sooner, and a second stop does nothing.
|
|
12
|
+
*/
|
|
13
|
+
export function startStubClosingListener(path: string): StubClosingListener {
|
|
14
|
+
const server = Bun.listen({
|
|
15
|
+
unix: path,
|
|
16
|
+
socket: {
|
|
17
|
+
open(socket) {
|
|
18
|
+
socket.end();
|
|
19
|
+
},
|
|
20
|
+
data() {},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const stop = registerTestCleanup(() => {
|
|
25
|
+
server.stop(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
[Symbol.dispose]: stop,
|
|
30
|
+
};
|
|
31
|
+
}
|