@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.
- 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 +62 -13
- 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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the effect ref of the one idempotency claim a state database
|
|
5
|
+
* holds, and throws when it holds none or several, so a test that expects
|
|
6
|
+
* one claim never reads another's.
|
|
7
|
+
*/
|
|
8
|
+
export function getOnlyEffectRef(db: Database): string {
|
|
9
|
+
const refs = db
|
|
10
|
+
.query<{ effect_ref: string }, []>('SELECT effect_ref FROM idempotency')
|
|
11
|
+
.all()
|
|
12
|
+
.map((row) => row.effect_ref);
|
|
13
|
+
|
|
14
|
+
const [ref] = refs;
|
|
15
|
+
|
|
16
|
+
if (refs.length !== 1 || ref === undefined) {
|
|
17
|
+
throw new Error(`expected one idempotency claim, found ${JSON.stringify(refs)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return ref;
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { buildStubImpPort } from './build-stub-imp-port';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the name of the one imp a stub imp port holds, and throws when
|
|
5
|
+
* it holds none or several, so a test that expects one spawn's imp never
|
|
6
|
+
* reads another's.
|
|
7
|
+
*/
|
|
8
|
+
export function getOnlyImpName(
|
|
9
|
+
port: Readonly<Pick<ReturnType<typeof buildStubImpPort>, 'collectImpNames'>>,
|
|
10
|
+
): string {
|
|
11
|
+
const names = port.collectImpNames();
|
|
12
|
+
const [name] = names;
|
|
13
|
+
|
|
14
|
+
if (names.length !== 1 || name === undefined) {
|
|
15
|
+
throw new Error(`expected one imp, found ${JSON.stringify(names)}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Narrows one field of a record to an array of records, for tests reading a
|
|
5
|
+
* list out of a loosely typed protocol reply, such as the sessions of
|
|
6
|
+
* `session.list`. Throws when the field is not an array or holds anything
|
|
7
|
+
* but records, so a malformed reply fails the test instead of passing it.
|
|
8
|
+
*/
|
|
9
|
+
export function getRecords(
|
|
10
|
+
value: Readonly<Record<string, unknown>>,
|
|
11
|
+
key: string,
|
|
12
|
+
): Record<string, unknown>[] {
|
|
13
|
+
const inner = value[key];
|
|
14
|
+
|
|
15
|
+
if (!Array.isArray(inner) || !inner.every((item) => isRecord(item))) {
|
|
16
|
+
throw new TypeError(`${key} is not an array of records`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return inner;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrows one field of a record to a string, for tests pulling an id or a
|
|
3
|
+
* command out of a loosely typed protocol reply. Throws rather than letting a
|
|
4
|
+
* malformed reply pass a test vacuously.
|
|
5
|
+
*/
|
|
6
|
+
export function getString(value: Readonly<Record<string, unknown>>, key: string): string {
|
|
7
|
+
const inner = value[key];
|
|
8
|
+
|
|
9
|
+
if (typeof inner !== 'string') {
|
|
10
|
+
throw new TypeError(`${key} is not a string`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return inner;
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control bytes and escape sequences tests type into a terminal, by
|
|
3
|
+
* name, so a test reads `KEYS.esc` where it would otherwise hold an opaque
|
|
4
|
+
* escape. `bel` is the one entry a terminal writes rather than reads: the
|
|
5
|
+
* byte that ends an OSC sequence on screen.
|
|
6
|
+
*/
|
|
7
|
+
export const KEYS = Object.freeze({
|
|
8
|
+
ctrlSpace: '\u0000',
|
|
9
|
+
ctrlA: '\u0001',
|
|
10
|
+
ctrlB: '\u0002',
|
|
11
|
+
ctrlC: '\u0003',
|
|
12
|
+
bel: '\u0007',
|
|
13
|
+
tab: '\u0009',
|
|
14
|
+
ctrlJ: '\u000A',
|
|
15
|
+
enter: '\u000D',
|
|
16
|
+
ctrlU: '\u0015',
|
|
17
|
+
esc: '\u001B',
|
|
18
|
+
ctrlRightBracket: '\u001D',
|
|
19
|
+
backspace: '\u007F',
|
|
20
|
+
up: '\u001B[A',
|
|
21
|
+
down: '\u001B[B',
|
|
22
|
+
right: '\u001B[C',
|
|
23
|
+
left: '\u001B[D',
|
|
24
|
+
pasteOpen: '\u001B[200~',
|
|
25
|
+
pasteClose: '\u001B[201~',
|
|
26
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MockOverrides } from './mock-overrides';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A copy of the defaults with each override laid over it: a plain object
|
|
5
|
+
* on both sides merges key by key at every depth, and any other value,
|
|
6
|
+
* arrays and class instances included, replaces the default whole. Neither
|
|
7
|
+
* argument changes. A mock factory passes its fresh defaults here, so a
|
|
8
|
+
* nested override keeps every default beside it. `K` holds the keys the
|
|
9
|
+
* defaults set; an override of any other key must be whole.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeDeep<T extends object, K extends keyof T = keyof T>(
|
|
12
|
+
defaults: T,
|
|
13
|
+
overrides: MockOverrides<T, K>,
|
|
14
|
+
): T {
|
|
15
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- merging a deep partial of T over a whole T yields a whole T
|
|
16
|
+
return mergeRecords(defaults, overrides) as T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mergeRecords(base: object, patch: object): Record<string, unknown> {
|
|
20
|
+
const merged: Record<string, unknown> = { ...base };
|
|
21
|
+
|
|
22
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
23
|
+
const current = merged[key];
|
|
24
|
+
|
|
25
|
+
merged[key] =
|
|
26
|
+
isPlainObject(current) && isPlainObject(value) ? mergeRecords(current, value) : value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return merged;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value: unknown): value is object {
|
|
33
|
+
return (
|
|
34
|
+
typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PartialDeep } from 'type-fest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The overrides a mock factory takes for a type whose defaults set the
|
|
5
|
+
* keys `K`: a field the defaults set takes a deep partial, merged into the
|
|
6
|
+
* default, and any other field takes a whole value, since there is no
|
|
7
|
+
* default to merge a partial one into.
|
|
8
|
+
*/
|
|
9
|
+
export type MockOverrides<T, K extends keyof T> = {
|
|
10
|
+
readonly [P in K]?: PartialDeep<T[P]>;
|
|
11
|
+
} & {
|
|
12
|
+
readonly [P in Exclude<keyof T, K>]?: T[P];
|
|
13
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { KEYS } from './keys';
|
|
2
|
+
import type { TUIHarness } from './start-tui-harness';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Opens the GitHub repository step of a Claude spawn from the home screen:
|
|
6
|
+
* the first agent, then a tab from the directory step to the next source.
|
|
7
|
+
* Resolves once the step's title is drawn, with the capture holding what
|
|
8
|
+
* the client drew since the tab.
|
|
9
|
+
*/
|
|
10
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a harness is a live handle
|
|
11
|
+
export async function openRepoStep(tui: TUIHarness): Promise<void> {
|
|
12
|
+
tui.write('n');
|
|
13
|
+
|
|
14
|
+
await tui.waitFor('spawn: agent');
|
|
15
|
+
|
|
16
|
+
tui.write(KEYS.enter);
|
|
17
|
+
|
|
18
|
+
await tui.waitFor('spawn: directory');
|
|
19
|
+
|
|
20
|
+
tui.reset();
|
|
21
|
+
tui.write(KEYS.tab);
|
|
22
|
+
|
|
23
|
+
await tui.waitFor('spawn: GitHub repository');
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { SQLQueryBindings } from 'bun:sqlite';
|
|
3
|
+
import type { CompiledQuery } from 'kysely';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads the plan SQLite picks for one compiled query against the database
|
|
7
|
+
* at the path, bound to the query's own parameters, as the plan's detail
|
|
8
|
+
* lines joined by newlines. It opens the database read-only and closes it
|
|
9
|
+
* before returning, so the query never runs and nothing is written.
|
|
10
|
+
*/
|
|
11
|
+
export function readQueryPlan(dbPath: string, query: CompiledQuery): string {
|
|
12
|
+
using db = new Database(dbPath, { readonly: true });
|
|
13
|
+
|
|
14
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- kysely's sqlite compiler only ever binds bun:sqlite-legal values
|
|
15
|
+
const bindings = query.parameters as SQLQueryBindings[];
|
|
16
|
+
|
|
17
|
+
return db
|
|
18
|
+
.query<{ detail: string }, SQLQueryBindings[]>(`EXPLAIN QUERY PLAN ${query.sql}`)
|
|
19
|
+
.all(...bindings)
|
|
20
|
+
.map((row) => row.detail)
|
|
21
|
+
.join('\n');
|
|
22
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The command an end-to-end suite runs atc as: the compiled binary that
|
|
5
|
+
* `ATC_BIN` holds when a smoke run sets it, or else the source entry under
|
|
6
|
+
* the test's own bun, so one suite proves both. Read at each call, so a test
|
|
7
|
+
* that sets the variable sees its own value.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveATCCommand(): string[] {
|
|
10
|
+
const bin = process.env['ATC_BIN'];
|
|
11
|
+
|
|
12
|
+
return bin === undefined ? [process.execPath, join(import.meta.dir, '..', 'cli.ts')] : [bin];
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The command a test runs `atc-gateway` as: the compiled binary at the given
|
|
5
|
+
* path, which a smoke run of a release build passes in through
|
|
6
|
+
* `ATC_GATEWAY_BIN`, or without one the source entry under the test's own
|
|
7
|
+
* bun, so one gateway suite proves both.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveGatewayCommand(binary: string | undefined): readonly string[] {
|
|
10
|
+
return binary === undefined
|
|
11
|
+
? [process.execPath, join(import.meta.dir, '..', 'gateway.ts')]
|
|
12
|
+
: [binary];
|
|
13
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
interface ATCRun {
|
|
2
|
+
// The command atc runs as, such as the source entry under bun or a
|
|
3
|
+
// compiled binary.
|
|
4
|
+
readonly command: readonly string[];
|
|
5
|
+
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
|
|
8
|
+
// HOME and XDG_RUNTIME_DIR for the run, so it finds the daemon and the
|
|
9
|
+
// state of this home alone.
|
|
10
|
+
readonly home: string;
|
|
11
|
+
|
|
12
|
+
// Variables laid over the inherited environment; `undefined` removes one.
|
|
13
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
|
|
15
|
+
readonly stdin?: string;
|
|
16
|
+
readonly cwd?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ATCResult {
|
|
20
|
+
readonly exitCode: number;
|
|
21
|
+
readonly stdout: string;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runs one atc subcommand as its own process against a home and resolves
|
|
27
|
+
* once it exits, with its exit code and everything it printed. The process
|
|
28
|
+
* inherits the test's environment with HOME and XDG_RUNTIME_DIR pointed at
|
|
29
|
+
* the home, then the run's own variables over that.
|
|
30
|
+
*/
|
|
31
|
+
export async function runATC(run: Readonly<ATCRun>): Promise<ATCResult> {
|
|
32
|
+
const env = Object.fromEntries(
|
|
33
|
+
Object.entries({
|
|
34
|
+
...process.env,
|
|
35
|
+
HOME: run.home,
|
|
36
|
+
XDG_RUNTIME_DIR: run.home,
|
|
37
|
+
...run.env,
|
|
38
|
+
}).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const proc = Bun.spawn([...run.command, ...run.args], {
|
|
42
|
+
env,
|
|
43
|
+
...(run.cwd === undefined ? {} : { cwd: run.cwd }),
|
|
44
|
+
stdin: run.stdin === undefined ? 'ignore' : new TextEncoder().encode(run.stdin),
|
|
45
|
+
stdout: 'pipe',
|
|
46
|
+
stderr: 'pipe',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
50
|
+
new Response(proc.stdout).text(),
|
|
51
|
+
new Response(proc.stderr).text(),
|
|
52
|
+
proc.exited,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
return { exitCode, stdout, stderr };
|
|
56
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { PROTOCOL_V } from '../protocol/protocol';
|
|
4
|
-
import {
|
|
4
|
+
import { startStubLegacyDaemon } from './start-stub-legacy-daemon';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Runs a daemon of another protocol version as its own process, recorded in
|
|
8
8
|
* the state directory the way a real daemon records itself, so a client
|
|
9
9
|
* that finds it by its record can stop it. Arguments: the socket path to
|
|
10
10
|
* listen on and the state directory. It refuses every handshake with
|
|
11
|
-
* `protocol_mismatch
|
|
12
|
-
*
|
|
11
|
+
* `protocol_mismatch` and hosts one session, a child process that sleeps for
|
|
12
|
+
* a minute. Once it listens it prints `up` and the session's pid on one
|
|
13
|
+
* line, and it runs until it is killed. On SIGTERM or SIGINT it kills and
|
|
14
|
+
* reaps the session, then dies of the same signal, and it kills the session
|
|
15
|
+
* whenever it exits, so a caller that stops only the daemon leaves nothing
|
|
16
|
+
* behind.
|
|
13
17
|
*/
|
|
14
18
|
function main() {
|
|
15
19
|
const socketPath = process.argv.at(2);
|
|
@@ -19,7 +23,25 @@ function main() {
|
|
|
19
23
|
throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1 });
|
|
27
|
+
|
|
28
|
+
const session = Bun.spawn(['sleep', '60']);
|
|
29
|
+
|
|
30
|
+
process.on('exit', () => {
|
|
31
|
+
session.kill('SIGKILL');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
35
|
+
process.once(signal, () => {
|
|
36
|
+
void (async () => {
|
|
37
|
+
session.kill('SIGKILL');
|
|
38
|
+
|
|
39
|
+
await session.exited;
|
|
40
|
+
|
|
41
|
+
process.kill(process.pid, signal);
|
|
42
|
+
})();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
23
45
|
|
|
24
46
|
writeFileSync(
|
|
25
47
|
join(stateDir, 'daemon.json'),
|
|
@@ -32,7 +54,7 @@ function main() {
|
|
|
32
54
|
}),
|
|
33
55
|
);
|
|
34
56
|
|
|
35
|
-
console.log(
|
|
57
|
+
console.log(`up ${session.pid}`);
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
main();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { startDaemon } from '../daemon/daemon';
|
|
3
|
+
import { buildMockAgentAdapter } from './build-mock-agent-adapter';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Runs a daemon with a TCP listener on a kernel-chosen loopback port, its
|
|
@@ -15,17 +16,11 @@ async function main() {
|
|
|
15
16
|
socketPath: join(dir, 'daemon.sock'),
|
|
16
17
|
reporterSocketPath: join(dir, 'reporter.sock'),
|
|
17
18
|
build: 'atc/test-build',
|
|
18
|
-
adapter: {
|
|
19
|
-
id: 'claude',
|
|
20
|
-
screenDetector: null,
|
|
19
|
+
adapter: buildMockAgentAdapter({
|
|
21
20
|
takesMessages: true,
|
|
22
|
-
headlessRunner: null,
|
|
23
|
-
planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
|
|
24
21
|
normalizeHook: () => ({ kind: 'prompt-submitted' }),
|
|
25
|
-
loadName: () => Promise.resolve(null),
|
|
26
|
-
canResume: () => true,
|
|
27
22
|
buildResumeCommand: () => 'claude --resume',
|
|
28
|
-
},
|
|
23
|
+
}),
|
|
29
24
|
dbPath: join(dir, 'state.db'),
|
|
30
25
|
statusPath: join(dir, 'status.json'),
|
|
31
26
|
listen: {
|
|
@@ -37,8 +32,6 @@ async function main() {
|
|
|
37
32
|
},
|
|
38
33
|
});
|
|
39
34
|
|
|
40
|
-
process.stdout.write(`${String(handle.listenPort)}\n`);
|
|
41
|
-
|
|
42
35
|
process.on('SIGTERM', () => {
|
|
43
36
|
void (async () => {
|
|
44
37
|
await handle.stop();
|
|
@@ -46,6 +39,8 @@ async function main() {
|
|
|
46
39
|
process.exit(0);
|
|
47
40
|
})();
|
|
48
41
|
});
|
|
42
|
+
|
|
43
|
+
process.stdout.write(`${String(handle.listenPort)}\n`);
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
await main();
|
|
@@ -96,8 +96,6 @@ async function main() {
|
|
|
96
96
|
gitTransports: config.workspaces.gitTransports,
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
process.stdout.write('up\n');
|
|
100
|
-
|
|
101
99
|
process.on('SIGTERM', () => {
|
|
102
100
|
void (async () => {
|
|
103
101
|
await handle.stop();
|
|
@@ -105,6 +103,8 @@ async function main() {
|
|
|
105
103
|
process.exit(0);
|
|
106
104
|
})();
|
|
107
105
|
});
|
|
106
|
+
|
|
107
|
+
process.stdout.write('up\n');
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
await main();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
|
|
3
|
+
// Seeds faker once for the whole run and fixes the date its relative dates
|
|
4
|
+
// count from, so a failing run's factory values, dates included, come out
|
|
5
|
+
// the same on the next run.
|
|
6
|
+
faker.seed(48);
|
|
7
|
+
faker.setDefaultRefDate(new Date('2026-01-01T00:00:00.000Z'));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// How many connections are open at once, so a flood stays within the
|
|
2
|
+
// host's open-file limit.
|
|
3
|
+
const BATCH = 100;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Opens `count` TCP connections to the loopback port, each on its own,
|
|
7
|
+
* writes one line that is not a handshake on each, and resolves once the
|
|
8
|
+
* server has closed every one, as a daemon's listener closes a connection
|
|
9
|
+
* it refuses.
|
|
10
|
+
*/
|
|
11
|
+
export async function sendLinesBeforeHandshake(port: number, count: number): Promise<void> {
|
|
12
|
+
for (let start = 0; start < count; start += BATCH) {
|
|
13
|
+
await Promise.all(
|
|
14
|
+
Array.from({ length: Math.min(BATCH, count - start) }, async () => {
|
|
15
|
+
const closed = Promise.withResolvers<void>();
|
|
16
|
+
|
|
17
|
+
await Bun.connect({
|
|
18
|
+
hostname: '127.0.0.1',
|
|
19
|
+
port,
|
|
20
|
+
socket: {
|
|
21
|
+
open(socket) {
|
|
22
|
+
socket.write('not a handshake\n');
|
|
23
|
+
},
|
|
24
|
+
data() {},
|
|
25
|
+
close() {
|
|
26
|
+
closed.resolve();
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await closed.promise;
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getRecord } from '../shared/get-record';
|
|
2
|
+
import { readJSONRecord } from './read-json-record';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Posts one JSON-RPC request to an MCP HTTP server's `/mcp` endpoint with
|
|
6
|
+
* the given access token as a bearer token, and returns the response's
|
|
7
|
+
* `result`. Throws when the body is not a JSON object with an object
|
|
8
|
+
* `result`, such as a JSON-RPC error.
|
|
9
|
+
*/
|
|
10
|
+
export async function sendMCPRequest(
|
|
11
|
+
url: string,
|
|
12
|
+
token: string,
|
|
13
|
+
method: string,
|
|
14
|
+
params: Readonly<Record<string, unknown>> = {},
|
|
15
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
16
|
+
const answered = await fetch(`${url}/mcp`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const body = await readJSONRecord(answered);
|
|
23
|
+
|
|
24
|
+
return getRecord(body, 'result');
|
|
25
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { findPidFilePID } from '../shared/find-pid-file-pid';
|
|
4
|
+
import { stopDaemonProcess } from '../stop-daemon-process';
|
|
5
|
+
import { buildStubMCPClaude } from './build-stub-mcp-claude';
|
|
6
|
+
import { buildStubMCPGrok } from './build-stub-mcp-grok';
|
|
7
|
+
import { createStubBin } from './create-stub-bin';
|
|
8
|
+
import { setupTempDir } from './setup-temp-dir';
|
|
9
|
+
import { waitFor } from './wait-for';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A temp home for running `atc mcp` against, with the stand-in `claude` and
|
|
13
|
+
* `grok` at `claudeBin` and `grokBin` and a config that registers them with
|
|
14
|
+
* no extra arguments. A server started with this home as its `HOME` and
|
|
15
|
+
* `XDG_RUNTIME_DIR` boots its daemon here. Each stand-in appends its pid,
|
|
16
|
+
* which is its process group as a session leader, to `stub-pids` in the
|
|
17
|
+
* home. Disposal stops the daemon, when one wrote its pid file here, and
|
|
18
|
+
* waits for it to exit; then it kills the daemon's process group and every
|
|
19
|
+
* group `stub-pids` records, such as a reporter a stopped session left
|
|
20
|
+
* behind, waits until each group is empty, and removes the home. Hold the
|
|
21
|
+
* result with `await using`.
|
|
22
|
+
*/
|
|
23
|
+
export function setupMCPHome() {
|
|
24
|
+
const tmp = setupTempDir('atc-mcp-');
|
|
25
|
+
const home = tmp.dir;
|
|
26
|
+
|
|
27
|
+
mkdirSync(join(home, '.config', 'atc'), { recursive: true });
|
|
28
|
+
mkdirSync(join(home, '.local', 'state', 'atc'), { recursive: true });
|
|
29
|
+
|
|
30
|
+
const claudeBin = createStubBin(home, 'fake-claude', buildStubMCPClaude());
|
|
31
|
+
const grokBin = createStubBin(home, 'fake-grok', buildStubMCPGrok());
|
|
32
|
+
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(home, '.config', 'atc', 'config.json'),
|
|
35
|
+
JSON.stringify({ claudeBin, claudeArgs: [], grokBin, grokArgs: [] }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
home,
|
|
40
|
+
claudeBin,
|
|
41
|
+
grokBin,
|
|
42
|
+
async [Symbol.asyncDispose]() {
|
|
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
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The pids the stand-ins recorded in the home, one per line; none when no
|
|
58
|
+
* stand-in ran.
|
|
59
|
+
*/
|
|
60
|
+
function readStubPIDs(home: string): number[] {
|
|
61
|
+
const path = join(home, 'stub-pids');
|
|
62
|
+
|
|
63
|
+
if (!existsSync(path)) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return readFileSync(path, 'utf8')
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter((line) => line !== '')
|
|
70
|
+
.map(Number);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function killProcessGroups(groups: readonly number[]): Promise<void> {
|
|
74
|
+
for (const group of groups) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-group, 'SIGKILL');
|
|
77
|
+
} catch {
|
|
78
|
+
// The group is already empty.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
const running = groups.filter((group) => isProcessGroupRunning(group));
|
|
84
|
+
|
|
85
|
+
if (running.length > 0) {
|
|
86
|
+
throw new Error(`process groups still running: ${running.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isProcessGroupRunning(group: number): boolean {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(-group, 0);
|
|
94
|
+
|
|
95
|
+
return true;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return !(error instanceof Error && Reflect.get(error, 'code') === 'ESRCH');
|
|
98
|
+
}
|
|
99
|
+
}
|