@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,81 @@
|
|
|
1
|
+
import type { DaemonContext } from '../daemon/daemon-context';
|
|
2
|
+
import { TargetAccess } from '../daemon/target-access';
|
|
3
|
+
import { toDaemonID } from '../shared/to-daemon-id';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The daemon a connection serves, for tests that drive one connection
|
|
7
|
+
* without a daemon behind it: build `atc/test`, daemon id `d-1`, a 64 KiB
|
|
8
|
+
* outbound queue, a day's idempotency retention, no sessions, `claude` as
|
|
9
|
+
* the last agent used, an empty fleet, a principal that reaches no target,
|
|
10
|
+
* every session bound to the `local` target and visible, and resyncs and
|
|
11
|
+
* detaches that do nothing. Every other
|
|
12
|
+
* member throws when called, naming itself, so a test that reaches one it
|
|
13
|
+
* did not wire fails loudly. An override replaces the member it names.
|
|
14
|
+
*/
|
|
15
|
+
export function buildStubDaemonContext(overrides: Partial<DaemonContext> = {}): DaemonContext {
|
|
16
|
+
return {
|
|
17
|
+
build: 'atc/test',
|
|
18
|
+
daemonID: toDaemonID('d-1'),
|
|
19
|
+
queueBytes: 65_536,
|
|
20
|
+
idempotencyRetentionMs: 86_400_000,
|
|
21
|
+
collectSessions: () => [],
|
|
22
|
+
collectSpawnDirs: makeUnreached('collectSpawnDirs'),
|
|
23
|
+
collectAgents: makeUnreached('collectAgents'),
|
|
24
|
+
collectFleet: () => Promise.resolve([]),
|
|
25
|
+
loadLastUsedAgent: () => Promise.resolve('claude'),
|
|
26
|
+
findAdapter: makeUnreached('findAdapter'),
|
|
27
|
+
buildTargetAccess: () => new TargetAccess([]),
|
|
28
|
+
hasListedPrincipal: makeUnreached('hasListedPrincipal'),
|
|
29
|
+
findSessionGrant: () => ({ target: 'local', targetIdentity: 'local-pty' }),
|
|
30
|
+
findTargetIdentity: makeUnreached('findTargetIdentity'),
|
|
31
|
+
canSeeSession: () => true,
|
|
32
|
+
isSessionVisible: makeUnreached('isSessionVisible'),
|
|
33
|
+
findPermissionSession: makeUnreached('findPermissionSession'),
|
|
34
|
+
resolveSpawnParent: makeUnreached('resolveSpawnParent'),
|
|
35
|
+
resolveSpawnTarget: makeUnreached('resolveSpawnTarget'),
|
|
36
|
+
requireWorkspaceTarget: makeUnreached('requireWorkspaceTarget'),
|
|
37
|
+
buildDefaultWorkspaceDir: makeUnreached('buildDefaultWorkspaceDir'),
|
|
38
|
+
requireAgentTarget: makeUnreached('requireAgentTarget'),
|
|
39
|
+
findSource: makeUnreached('findSource'),
|
|
40
|
+
collectAlternateGitURLs: makeUnreached('collectAlternateGitURLs'),
|
|
41
|
+
checkRepositoryAccess: makeUnreached('checkRepositoryAccess'),
|
|
42
|
+
spawnSession: makeUnreached('spawnSession'),
|
|
43
|
+
killSession: makeUnreached('killSession'),
|
|
44
|
+
forgetSession: makeUnreached('forgetSession'),
|
|
45
|
+
revokeSessionAuth: makeUnreached('revokeSessionAuth'),
|
|
46
|
+
updateSessionAuth: makeUnreached('updateSessionAuth'),
|
|
47
|
+
updateSession: makeUnreached('updateSession'),
|
|
48
|
+
quitDaemon: makeUnreached('quitDaemon'),
|
|
49
|
+
ackSession: makeUnreached('ackSession'),
|
|
50
|
+
buildResumeCommand: makeUnreached('buildResumeCommand'),
|
|
51
|
+
readSessionScreen: makeUnreached('readSessionScreen'),
|
|
52
|
+
readSessionRecord: makeUnreached('readSessionRecord'),
|
|
53
|
+
loadSessionTranscript: makeUnreached('loadSessionTranscript'),
|
|
54
|
+
readEvents: makeUnreached('readEvents'),
|
|
55
|
+
readReport: makeUnreached('readReport'),
|
|
56
|
+
answerPermission: makeUnreached('answerPermission'),
|
|
57
|
+
restoreFleet: makeUnreached('restoreFleet'),
|
|
58
|
+
attachSession: makeUnreached('attachSession'),
|
|
59
|
+
detachSession: () => {},
|
|
60
|
+
detachClient: makeUnreached('detachClient'),
|
|
61
|
+
writeSessionInput: makeUnreached('writeSessionInput'),
|
|
62
|
+
writeSessionLine: makeUnreached('writeSessionLine'),
|
|
63
|
+
ejectSession: makeUnreached('ejectSession'),
|
|
64
|
+
adoptSession: makeUnreached('adoptSession'),
|
|
65
|
+
resizeSession: makeUnreached('resizeSession'),
|
|
66
|
+
resyncClient: () => Promise.resolve(),
|
|
67
|
+
getEffectiveDims: makeUnreached('getEffectiveDims'),
|
|
68
|
+
writeSessionMessage: makeUnreached('writeSessionMessage'),
|
|
69
|
+
readMessage: makeUnreached('readMessage'),
|
|
70
|
+
attachTap: makeUnreached('attachTap'),
|
|
71
|
+
detachTap: () => {},
|
|
72
|
+
ackMessage: makeUnreached('ackMessage'),
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeUnreached(member: string): () => never {
|
|
78
|
+
return () => {
|
|
79
|
+
throw new Error(`the stub daemon context has no ${member}`);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { waitFor } from './wait-for';
|
|
2
|
+
|
|
3
|
+
type DaemonAnswer = Readonly<Record<string, unknown>>;
|
|
4
|
+
|
|
5
|
+
interface StubRequest {
|
|
6
|
+
readonly m: string;
|
|
7
|
+
readonly p: DaemonAnswer;
|
|
8
|
+
readonly resolve: (answer: DaemonAnswer) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StubDaemonRequestsOptions {
|
|
12
|
+
// A count the client under test raises each time it acts on something,
|
|
13
|
+
// such as a draw or a callback it makes.
|
|
14
|
+
readonly countReactions: () => number;
|
|
15
|
+
|
|
16
|
+
// Milliseconds each wait for a request or a reaction lasts before it
|
|
17
|
+
// rejects; five seconds when absent.
|
|
18
|
+
readonly timeoutMs?: number;
|
|
19
|
+
|
|
20
|
+
// The clock each wait reads its deadline from; the wall clock when absent.
|
|
21
|
+
readonly now?: () => number;
|
|
22
|
+
|
|
23
|
+
// Waits out the pause between a wait's checks; a real sleep when absent.
|
|
24
|
+
readonly wait?: (ms: number) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A daemon request channel that answers only when the test says so. Every
|
|
29
|
+
* request sent through `sendRequest` waits until `answer` or
|
|
30
|
+
* `answerOldest` resolves it. Each of those waits for a request under the
|
|
31
|
+
* method to be waiting, resolves the latest or the oldest one, and then
|
|
32
|
+
* waits for the client to react: to send another request, or to raise the
|
|
33
|
+
* count `countReactions` reads. A wait that outlasts `timeoutMs` rejects:
|
|
34
|
+
* with `no <method> request is waiting for an answer` when nothing was
|
|
35
|
+
* sent under the method, and with `nothing reacted to the <method> answer`
|
|
36
|
+
* when the client never reacted. A test that takes the `now` and `wait`
|
|
37
|
+
* of a stub clock steps that timeout without waiting it out. `collectSent` returns the params of every
|
|
38
|
+
* request sent under a method, answered or not, in the order they were
|
|
39
|
+
* sent.
|
|
40
|
+
*/
|
|
41
|
+
export function buildStubDaemonRequests(options: StubDaemonRequestsOptions) {
|
|
42
|
+
const sent: StubRequest[] = [];
|
|
43
|
+
|
|
44
|
+
const answered = new Set<StubRequest>();
|
|
45
|
+
|
|
46
|
+
const waitOptions = {
|
|
47
|
+
timeoutMs: options.timeoutMs ?? 5000,
|
|
48
|
+
now: options.now ?? Date.now,
|
|
49
|
+
wait: options.wait ?? Bun.sleep,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const countAll = () => sent.length + options.countReactions();
|
|
53
|
+
|
|
54
|
+
const resolveAndWait = async (request: Readonly<StubRequest>, value: DaemonAnswer) => {
|
|
55
|
+
const before = countAll();
|
|
56
|
+
|
|
57
|
+
answered.add(request);
|
|
58
|
+
request.resolve(value);
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
if (countAll() <= before) {
|
|
62
|
+
throw new Error(`nothing reacted to the ${request.m} answer`);
|
|
63
|
+
}
|
|
64
|
+
}, waitOptions);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
sendRequest(m: string, p: DaemonAnswer = {}): Promise<DaemonAnswer> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
sent.push({ m, p, resolve });
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
async answer(m: string, value: DaemonAnswer): Promise<void> {
|
|
74
|
+
const request = await waitFor(() => {
|
|
75
|
+
const latest = sent.findLast((r) => r.m === m && !answered.has(r));
|
|
76
|
+
|
|
77
|
+
if (latest === undefined) {
|
|
78
|
+
throw new Error(`no ${m} request is waiting for an answer`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return latest;
|
|
82
|
+
}, waitOptions);
|
|
83
|
+
|
|
84
|
+
await resolveAndWait(request, value);
|
|
85
|
+
},
|
|
86
|
+
async answerOldest(m: string, value: DaemonAnswer): Promise<void> {
|
|
87
|
+
const request = await waitFor(() => {
|
|
88
|
+
const oldest = sent.find((r) => r.m === m && !answered.has(r));
|
|
89
|
+
|
|
90
|
+
if (oldest === undefined) {
|
|
91
|
+
throw new Error(`no ${m} request is waiting for an answer`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return oldest;
|
|
95
|
+
}, waitOptions);
|
|
96
|
+
|
|
97
|
+
await resolveAndWait(request, value);
|
|
98
|
+
},
|
|
99
|
+
collectSent(m: string): DaemonAnswer[] {
|
|
100
|
+
return sent.filter((request) => request.m === m).map((request) => request.p);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -11,11 +11,11 @@ import type {
|
|
|
11
11
|
import { LocalPTYProvider } from '../daemon/local-pty-provider';
|
|
12
12
|
|
|
13
13
|
// One provider operation, in the order the daemon called it.
|
|
14
|
-
type
|
|
14
|
+
type StubDirCall =
|
|
15
15
|
| { readonly op: 'transfer'; readonly dir: string; readonly bytes: number }
|
|
16
16
|
| { readonly op: 'run'; readonly argv: readonly string[]; readonly cwd: string };
|
|
17
17
|
|
|
18
|
-
interface
|
|
18
|
+
interface StubDirOptions {
|
|
19
19
|
// Capabilities the provider declares as missing.
|
|
20
20
|
readonly lacking?: readonly ExecutionCapability[];
|
|
21
21
|
|
|
@@ -25,22 +25,16 @@ interface FixtureDirOptions {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
29
|
-
* owns: a transfer unpacks the archive with tar into the directory it is
|
|
30
|
-
* given, and a command runs as a child process in its working directory.
|
|
31
|
-
* Paths pass through unchanged, so a test points them into its own temp
|
|
32
|
-
* tree. Every transfer and command is recorded in `calls`. Harnesses start
|
|
33
|
-
* on a local pseudo-terminal, and each one's spec is recorded in
|
|
34
|
-
* `harnesses`.
|
|
28
|
+
* The directory-tree provider the factory below builds, one per call.
|
|
35
29
|
*/
|
|
36
|
-
|
|
30
|
+
class StubDirProvider implements ExecutionProvider {
|
|
37
31
|
readonly kind = 'fixture-dir';
|
|
38
32
|
|
|
39
33
|
readonly remote = false;
|
|
40
34
|
|
|
41
35
|
readonly capabilities: ExecutionCapabilities;
|
|
42
36
|
|
|
43
|
-
readonly calls:
|
|
37
|
+
readonly calls: StubDirCall[] = [];
|
|
44
38
|
|
|
45
39
|
// Every harness the provider started, as the daemon specified it.
|
|
46
40
|
readonly harnesses: HarnessSpec[] = [];
|
|
@@ -49,7 +43,7 @@ export class FixtureDirProvider implements ExecutionProvider {
|
|
|
49
43
|
|
|
50
44
|
private readonly terminals = new LocalPTYProvider();
|
|
51
45
|
|
|
52
|
-
constructor(options:
|
|
46
|
+
constructor(options: StubDirOptions = {}) {
|
|
53
47
|
const lacking = new Set(options.lacking);
|
|
54
48
|
|
|
55
49
|
this.capabilities = {
|
|
@@ -129,3 +123,17 @@ export class FixtureDirProvider implements ExecutionProvider {
|
|
|
129
123
|
|
|
130
124
|
readonly dispose = (): void => {};
|
|
131
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds an execution provider of kind `fixture-dir` for tests whose host is
|
|
129
|
+
* a directory tree the test owns: a transfer unpacks the archive with tar
|
|
130
|
+
* into the directory it is given, and a command runs as a child process in
|
|
131
|
+
* its working directory. Paths pass through unchanged, so a test points them
|
|
132
|
+
* into its own temp tree. Every transfer and command is recorded in `calls`.
|
|
133
|
+
* Harnesses start on a local pseudo-terminal, and each one's spec is
|
|
134
|
+
* recorded in `harnesses`. Suspending or destroying a host rejects, since
|
|
135
|
+
* the test's own tree neither sleeps nor goes away.
|
|
136
|
+
*/
|
|
137
|
+
export function buildStubDirProvider(options: StubDirOptions = {}): StubDirProvider {
|
|
138
|
+
return new StubDirProvider(options);
|
|
139
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExecutionCapabilities,
|
|
3
|
+
ExecutionProvider,
|
|
4
|
+
HarnessSpec,
|
|
5
|
+
} from '../daemon/execution-provider';
|
|
6
|
+
import { LocalPTYProvider } from '../daemon/local-pty-provider';
|
|
7
|
+
|
|
8
|
+
interface StubExecutionProviderConfig {
|
|
9
|
+
// The provider kind the daemon reports for the target.
|
|
10
|
+
readonly kind?: string;
|
|
11
|
+
|
|
12
|
+
// Capabilities that differ from a local pseudo-terminal's.
|
|
13
|
+
readonly capabilities?: Partial<ExecutionCapabilities>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Called with each harness spec before the harness starts; a throw from
|
|
17
|
+
* it aborts the spawn.
|
|
18
|
+
*/
|
|
19
|
+
readonly onSpawn?: (spec: HarnessSpec) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface StubExecutionProvider extends ExecutionProvider {
|
|
23
|
+
// Every host the daemon put to sleep, in order.
|
|
24
|
+
readonly suspended: string[];
|
|
25
|
+
|
|
26
|
+
// Every host the daemon destroyed, in order.
|
|
27
|
+
readonly destroyed: string[];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rejects every host suspend with the error, recording nothing, until
|
|
31
|
+
* called with null.
|
|
32
|
+
*/
|
|
33
|
+
readonly setSuspendFailure: (error: Readonly<Error> | null) => void;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rejects every host destroy with the error, recording nothing, until
|
|
37
|
+
* called with null.
|
|
38
|
+
*/
|
|
39
|
+
readonly setDestroyFailure: (error: Readonly<Error> | null) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An execution provider for daemon tests that runs harnesses, transfers, and
|
|
44
|
+
* commands on this machine as the local pseudo-terminal provider does, with
|
|
45
|
+
* the capabilities the config changes on top of its own. It reports each
|
|
46
|
+
* harness spec to the config before starting it. A host suspend or
|
|
47
|
+
* destroy does nothing to the machine: it records the host in `suspended` or
|
|
48
|
+
* `destroyed` and resolves, or rejects with the error a failure setter gave.
|
|
49
|
+
* The daemon calls them only when the capabilities declare `suspend` or
|
|
50
|
+
* `destroy`.
|
|
51
|
+
*/
|
|
52
|
+
export function buildStubExecutionProvider(
|
|
53
|
+
config: StubExecutionProviderConfig = {},
|
|
54
|
+
): StubExecutionProvider {
|
|
55
|
+
const local = new LocalPTYProvider();
|
|
56
|
+
|
|
57
|
+
const onSpawn = config.onSpawn ?? (() => {});
|
|
58
|
+
const suspended: string[] = [];
|
|
59
|
+
const destroyed: string[] = [];
|
|
60
|
+
|
|
61
|
+
// The errors a suspend and a destroy reject with, or null while they
|
|
62
|
+
// succeed.
|
|
63
|
+
const failures: { suspend: Readonly<Error> | null; destroy: Readonly<Error> | null } = {
|
|
64
|
+
suspend: null,
|
|
65
|
+
destroy: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
kind: config.kind ?? 'stub',
|
|
70
|
+
remote: false,
|
|
71
|
+
capabilities: { ...local.capabilities, ...config.capabilities },
|
|
72
|
+
prepareHost: local.prepareHost,
|
|
73
|
+
spawnHarness: (spec) => {
|
|
74
|
+
onSpawn(spec);
|
|
75
|
+
|
|
76
|
+
return local.spawnHarness(spec);
|
|
77
|
+
},
|
|
78
|
+
transferArchive: local.transferArchive,
|
|
79
|
+
runCommand: local.runCommand,
|
|
80
|
+
suspendHost: (host) => {
|
|
81
|
+
const failure = failures.suspend;
|
|
82
|
+
|
|
83
|
+
if (failure !== null) {
|
|
84
|
+
return Promise.reject(failure);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
suspended.push(host);
|
|
88
|
+
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
},
|
|
91
|
+
destroyHost: (host) => {
|
|
92
|
+
const failure = failures.destroy;
|
|
93
|
+
|
|
94
|
+
if (failure !== null) {
|
|
95
|
+
return Promise.reject(failure);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
destroyed.push(host);
|
|
99
|
+
|
|
100
|
+
return Promise.resolve();
|
|
101
|
+
},
|
|
102
|
+
dispose: local.dispose,
|
|
103
|
+
suspended,
|
|
104
|
+
destroyed,
|
|
105
|
+
setSuspendFailure: (error) => {
|
|
106
|
+
failures.suspend = error;
|
|
107
|
+
},
|
|
108
|
+
setDestroyFailure: (error) => {
|
|
109
|
+
failures.destroy = error;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FleetCaller, FleetFeature } from '../mcp/types';
|
|
2
|
+
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
3
|
+
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One request a stub fleet caller received: the method and each further
|
|
7
|
+
* argument the sender passed, with an argument left undefined left out.
|
|
8
|
+
*/
|
|
9
|
+
interface StubFleetRequest {
|
|
10
|
+
readonly m: string;
|
|
11
|
+
readonly p?: Readonly<Record<string, unknown>>;
|
|
12
|
+
readonly required?: readonly DaemonFeature[];
|
|
13
|
+
readonly principal?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type StubFleetAnswer = (
|
|
17
|
+
request: StubFleetRequest,
|
|
18
|
+
) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
|
|
19
|
+
|
|
20
|
+
interface StubFleetCallerConfig {
|
|
21
|
+
// Gives the reply to one request: a value, a promise, or a throw, which
|
|
22
|
+
// the caller turns into a rejection, as a daemon refusal arrives.
|
|
23
|
+
readonly answer?: StubFleetAnswer;
|
|
24
|
+
|
|
25
|
+
// The features the caller reports its daemon announced.
|
|
26
|
+
readonly features?: readonly FleetFeature[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A fleet caller that stands in for a daemon connection: it records every
|
|
31
|
+
* request in `requests`, in the order sent, and replies through the
|
|
32
|
+
* config's `answer`, by default an empty record. It reports the config's
|
|
33
|
+
* `features`, by default every feature a current daemon announces, and it
|
|
34
|
+
* checks no request's required features against them, so a test that needs
|
|
35
|
+
* that refusal uses a real connection.
|
|
36
|
+
*/
|
|
37
|
+
export function buildStubFleetCaller(
|
|
38
|
+
config: StubFleetCallerConfig = {},
|
|
39
|
+
): FleetCaller & { readonly requests: readonly StubFleetRequest[] } {
|
|
40
|
+
const answer: StubFleetAnswer = config.answer ?? (() => ({}));
|
|
41
|
+
const features: readonly FleetFeature[] = config.features ?? DAEMON_FEATURES;
|
|
42
|
+
const requests: StubFleetRequest[] = [];
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
requests,
|
|
46
|
+
sendRequest: (m, p, required, principal) => {
|
|
47
|
+
const request: StubFleetRequest = {
|
|
48
|
+
m,
|
|
49
|
+
...(p === undefined ? {} : { p }),
|
|
50
|
+
...(required === undefined ? {} : { required }),
|
|
51
|
+
...(principal === undefined ? {} : { principal }),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
requests.push(request);
|
|
55
|
+
|
|
56
|
+
return Promise.try(() => answer(request));
|
|
57
|
+
},
|
|
58
|
+
readFeatures: () => Promise.resolve(new Set(features)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in `gh` that starts a child of its own and waits on
|
|
3
|
+
* it, as a gh extension might. It writes its own process ID, which is also
|
|
4
|
+
* the ID of the process group it leads when started detached, and its
|
|
5
|
+
* child's ID to `pidsFile`, one per line, moving the file into place so it
|
|
6
|
+
* appears whole. The child runs until it is killed.
|
|
7
|
+
*/
|
|
8
|
+
export function buildStubForkingGH(pidsFile: string): string {
|
|
9
|
+
return `#!/bin/sh\nsh -c 'sleep 30' &\nprintf '%s\\n%s\\n' $$ $! > '${pidsFile}.tmp'\nmv '${pidsFile}.tmp' '${pidsFile}'\nwait\n`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface StubGHReply {
|
|
2
|
+
readonly stdout?: string;
|
|
3
|
+
readonly stderr?: string;
|
|
4
|
+
readonly exitCode?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface StubGHOptions {
|
|
8
|
+
// What each command prints and exits with, keyed by its first argument,
|
|
9
|
+
// such as `repo` or `config`; `hang` waits until the command is killed.
|
|
10
|
+
// A command without a reply prints nothing and exits 0.
|
|
11
|
+
readonly replies: Readonly<Record<string, StubGHReply | 'hang'>>;
|
|
12
|
+
|
|
13
|
+
// A file each run appends its arguments to, as one line.
|
|
14
|
+
readonly argvFile?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The script of a stand-in `gh` that answers each command with the reply
|
|
19
|
+
* the test gave for its first argument: the stdout and stderr verbatim,
|
|
20
|
+
* then the exit code, 0 when unset. A `hang` reply waits until it is
|
|
21
|
+
* killed, as a gh that never answers does.
|
|
22
|
+
*/
|
|
23
|
+
export function buildStubGH(options: StubGHOptions): string {
|
|
24
|
+
const record =
|
|
25
|
+
options.argvFile === undefined
|
|
26
|
+
? ''
|
|
27
|
+
: `printf '%s\\n' "$*" >> ${toShellLiteral(options.argvFile)}\n`;
|
|
28
|
+
|
|
29
|
+
const cases = Object.entries(options.replies)
|
|
30
|
+
.map(([command, reply]) => ` ${toShellLiteral(command)}) ${renderReply(reply)} ;;\n`)
|
|
31
|
+
.join('');
|
|
32
|
+
|
|
33
|
+
return `#!/bin/sh\n${record}case "$1" in\n${cases}esac\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toShellLiteral(value: string): string {
|
|
37
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderReply(reply: StubGHReply | 'hang'): string {
|
|
41
|
+
if (reply === 'hang') {
|
|
42
|
+
return 'exec sleep 30';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
`printf '%s' ${toShellLiteral(reply.stdout ?? '')}`,
|
|
47
|
+
`printf '%s' ${toShellLiteral(reply.stderr ?? '')} >&2`,
|
|
48
|
+
`exit ${reply.exitCode ?? 0}`,
|
|
49
|
+
].join('; ');
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in `atc` whose daemon hands its socket to a
|
|
3
|
+
* replacement: it moves the socket listening at `listeningPath` to
|
|
4
|
+
* `$HOME/atc-daemon.sock`, where a client of a daemon on that home dials,
|
|
5
|
+
* and exits 0. Write it with `createStubBin`.
|
|
6
|
+
*/
|
|
7
|
+
export function buildStubHandoffDaemon(listeningPath: string): string {
|
|
8
|
+
return `#!/usr/bin/env bash
|
|
9
|
+
mv '${listeningPath}' "$HOME/atc-daemon.sock"
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { HarnessRelay } from '../daemon/execution-provider';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A harness relay held in memory, as a provider hands one to the daemon for
|
|
5
|
+
* a guest's connection. `sendLine` delivers a value as one JSON line from
|
|
6
|
+
* the guest to every line listener, `written` holds every line written back
|
|
7
|
+
* to the guest, parsed, in order, and `isClosed` turns true once the relay
|
|
8
|
+
* is closed. A write settles at once, as a relay with room does. `hangUp`
|
|
9
|
+
* runs every close listener, as a guest that hangs up does; closing the
|
|
10
|
+
* relay from the daemon's end runs none.
|
|
11
|
+
*/
|
|
12
|
+
export function buildStubHarnessRelay() {
|
|
13
|
+
const lineListeners: ((line: string) => void)[] = [];
|
|
14
|
+
const closeListeners: (() => void)[] = [];
|
|
15
|
+
const written: unknown[] = [];
|
|
16
|
+
let closed = false;
|
|
17
|
+
|
|
18
|
+
const relay: HarnessRelay = {
|
|
19
|
+
onLine: (listener) => {
|
|
20
|
+
lineListeners.push(listener);
|
|
21
|
+
},
|
|
22
|
+
onClose: (listener) => {
|
|
23
|
+
closeListeners.push(listener);
|
|
24
|
+
},
|
|
25
|
+
writeLine: (line) => {
|
|
26
|
+
written.push(JSON.parse(line));
|
|
27
|
+
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
},
|
|
30
|
+
close: () => {
|
|
31
|
+
closed = true;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
relay,
|
|
37
|
+
written,
|
|
38
|
+
isClosed: () => closed,
|
|
39
|
+
sendLine: (value: Readonly<Record<string, unknown>>) => {
|
|
40
|
+
for (const listener of lineListeners) {
|
|
41
|
+
listener(JSON.stringify(value));
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
hangUp: () => {
|
|
45
|
+
for (const listener of closeListeners) {
|
|
46
|
+
listener();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HeadlessRunEvents,
|
|
3
|
+
HeadlessRunRequest,
|
|
4
|
+
HeadlessRunner,
|
|
5
|
+
} from '../agents/agent-adapter';
|
|
6
|
+
|
|
7
|
+
interface StubHeadlessRun {
|
|
8
|
+
readonly request: HeadlessRunRequest;
|
|
9
|
+
|
|
10
|
+
// The events the daemon listens on, which the test calls to play the run.
|
|
11
|
+
readonly events: HeadlessRunEvents;
|
|
12
|
+
stopped: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StubHeadlessRunner {
|
|
16
|
+
readonly runner: HeadlessRunner;
|
|
17
|
+
|
|
18
|
+
// Every run the daemon started, in order, and whether it stopped it.
|
|
19
|
+
readonly runs: readonly StubHeadlessRun[];
|
|
20
|
+
|
|
21
|
+
// Resolves with the run at the index, counting from zero, once the
|
|
22
|
+
// daemon starts it.
|
|
23
|
+
readonly waitForRun: (index: number) => Promise<StubHeadlessRun>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A headless runner for daemon tests that starts no process. Each run the
|
|
28
|
+
* daemon starts is recorded in `runs`, in order, and does nothing until
|
|
29
|
+
* the test plays it by calling the run's events: output, a finished turn,
|
|
30
|
+
* or a turn that needs the user. A stop marks the run stopped and plays
|
|
31
|
+
* nothing, as a killed turn reports nothing more.
|
|
32
|
+
*/
|
|
33
|
+
export function buildStubHeadlessRunner(): StubHeadlessRunner {
|
|
34
|
+
const runs: StubHeadlessRun[] = [];
|
|
35
|
+
const started: PromiseWithResolvers<StubHeadlessRun>[] = [];
|
|
36
|
+
|
|
37
|
+
const getStarted = (index: number) => {
|
|
38
|
+
started[index] ??= Promise.withResolvers<StubHeadlessRun>();
|
|
39
|
+
|
|
40
|
+
return started[index];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
runner: (request, events) => {
|
|
45
|
+
const run: StubHeadlessRun = { request, events, stopped: false };
|
|
46
|
+
|
|
47
|
+
runs.push(run);
|
|
48
|
+
|
|
49
|
+
getStarted(runs.length - 1).resolve(run);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
stop: () => {
|
|
53
|
+
run.stopped = true;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
runs,
|
|
58
|
+
waitForRun: (index) => getStarted(index).promise,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script of a stand-in `gh` whose repository listing waits for the
|
|
3
|
+
* test. `gh config …` prints `https`. Any other command touches
|
|
4
|
+
* `$HOME/gh-held` and waits while `$HOME/gh-hold` exists, then prints one
|
|
5
|
+
* repository, `me/dots`, in the JSON shape `gh repo list` prints.
|
|
6
|
+
*/
|
|
7
|
+
export function buildStubHeldGH(): string {
|
|
8
|
+
return `#!/bin/sh
|
|
9
|
+
case "$1" in
|
|
10
|
+
config) echo https ;;
|
|
11
|
+
*)
|
|
12
|
+
if [ -f "$HOME/gh-hold" ]; then
|
|
13
|
+
touch "$HOME/gh-held"
|
|
14
|
+
while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
|
|
15
|
+
fi
|
|
16
|
+
echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
|
|
17
|
+
esac
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TapClient } from '../daemon/daemon-context';
|
|
2
|
+
import type { InboxSource } from '../daemon/drain-inbox';
|
|
3
|
+
import { TapRegistry } from '../daemon/tap-registry';
|
|
4
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
5
|
+
import type { SessionID } from '../shared/session-id';
|
|
6
|
+
import type { MessageRecord } from '../store/message-record';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An inbox source over a real, empty tap registry, standing in for the
|
|
10
|
+
* daemon's store. It holds every session under the given agent session id,
|
|
11
|
+
* and its pending-message read waits until the test answers it: `reading`
|
|
12
|
+
* settles once a read starts, `read` answers the read with the given
|
|
13
|
+
* messages whatever owner it asked for.
|
|
14
|
+
*/
|
|
15
|
+
export function buildStubHeldInboxSource(agentSessionID: AgentSessionID) {
|
|
16
|
+
const pending = Promise.withResolvers<MessageRecord[]>();
|
|
17
|
+
const reading = Promise.withResolvers<void>();
|
|
18
|
+
|
|
19
|
+
const source: InboxSource = {
|
|
20
|
+
taps: new TapRegistry<TapClient>(),
|
|
21
|
+
findLinkedOwner: (sessionID: SessionID) => ({ atcID: sessionID, agentSessionID }),
|
|
22
|
+
collectPendingMessages: () => {
|
|
23
|
+
reading.resolve();
|
|
24
|
+
|
|
25
|
+
return pending.promise;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
source,
|
|
31
|
+
reading: reading.promise,
|
|
32
|
+
read: (records: readonly MessageRecord[]) => {
|
|
33
|
+
pending.resolve([...records]);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|