@zgeoff/atc 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +30 -17
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { FFIType, dlopen, ptr } from 'bun:ffi';
|
|
2
2
|
import { closeSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import type { Clock } from './system-clock';
|
|
4
|
+
import { systemClock } from './system-clock';
|
|
3
5
|
|
|
4
6
|
export interface DaemonLock {
|
|
5
7
|
// Releases the lock; safe to call more than once.
|
|
@@ -24,11 +26,13 @@ const RETRY_MS = 50;
|
|
|
24
26
|
* racing at the same instant cannot both win, and the kernel drops it when
|
|
25
27
|
* the holding process dies, so a crashed daemon never blocks the next one.
|
|
26
28
|
* The file is never removed: removing it would let a newcomer lock a fresh
|
|
27
|
-
* inode while an older daemon still holds the unlinked one.
|
|
29
|
+
* inode while an older daemon still holds the unlinked one. The wait reads
|
|
30
|
+
* its deadline from `clock` and waits out each retry through it.
|
|
28
31
|
*/
|
|
29
32
|
export async function claimDaemonLock(
|
|
30
33
|
lockPath: string,
|
|
31
34
|
waitMs: number,
|
|
35
|
+
clock: Clock = systemClock,
|
|
32
36
|
): Promise<DaemonLock | null> {
|
|
33
37
|
writeFileSync(lockPath, '', { flag: 'a' });
|
|
34
38
|
|
|
@@ -39,16 +43,18 @@ export async function claimDaemonLock(
|
|
|
39
43
|
throw new Error(`atc daemon: cannot open the lock file ${lockPath}`);
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
const deadline =
|
|
46
|
+
const deadline = clock.now() + waitMs;
|
|
43
47
|
|
|
44
48
|
while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
|
|
45
|
-
if (
|
|
49
|
+
if (clock.now() >= deadline) {
|
|
46
50
|
closeSync(fd);
|
|
47
51
|
|
|
48
52
|
return null;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
await
|
|
55
|
+
await new Promise<void>((resolve) => {
|
|
56
|
+
clock.schedule(resolve, RETRY_MS);
|
|
57
|
+
});
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
let held = true;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
1
|
import { isRecord } from './report';
|
|
2
|
+
import { resolveHomeDir } from './resolve-home-dir';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Reads `dirs.roots`: the directories whose children the spawn picker
|
|
6
6
|
* lists. A non-string entry is dropped, a leading `~` expands to the home
|
|
7
7
|
* directory, and trailing slashes are trimmed so a root compares equal to
|
|
8
|
-
* the paths under it.
|
|
8
|
+
* the paths under it. `home` is the directory a `~` expands to.
|
|
9
9
|
*/
|
|
10
|
-
export function collectDirRoots(raw: unknown): readonly string[] {
|
|
10
|
+
export function collectDirRoots(raw: unknown, home: string = resolveHomeDir()): readonly string[] {
|
|
11
11
|
if (!isRecord(raw) || !Array.isArray(raw['roots'])) {
|
|
12
12
|
return [];
|
|
13
13
|
}
|
|
@@ -19,14 +19,14 @@ export function collectDirRoots(raw: unknown): readonly string[] {
|
|
|
19
19
|
continue;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
roots.push(normalizeRoot(item));
|
|
22
|
+
roots.push(normalizeRoot(item, home));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
return roots;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function normalizeRoot(dir: string): string {
|
|
29
|
-
const expanded = dir === '~' ?
|
|
28
|
+
function normalizeRoot(dir: string, home: string): string {
|
|
29
|
+
const expanded = dir === '~' ? home : dir.replace(/^~\//u, `${home}/`);
|
|
30
30
|
const trimmed = expanded.replace(/\/+$/u, '');
|
|
31
31
|
|
|
32
32
|
return trimmed === '' ? '/' : trimmed;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { homedir } from 'node:os';
|
|
2
1
|
import { z } from 'zod';
|
|
3
2
|
import { isRecord } from './report';
|
|
3
|
+
import { resolveHomeDir } from './resolve-home-dir';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One configured hook: a shell command the daemon runs when the named wire
|
|
@@ -30,9 +30,10 @@ const HOOK_ENTRY_SCHEMA = z.object({
|
|
|
30
30
|
* is left out; an event name with no valid entries is dropped. Unknown event
|
|
31
31
|
* names are kept as written — they never fire, and a future daemon that
|
|
32
32
|
* emits them starts firing without a config change. A `dir` is stored with
|
|
33
|
-
* `~` expanded and trailing slashes trimmed, ready for path
|
|
33
|
+
* `~` expanded to `home` and trailing slashes trimmed, ready for path
|
|
34
|
+
* matching.
|
|
34
35
|
*/
|
|
35
|
-
export function collectHooks(raw: unknown): HooksConfig {
|
|
36
|
+
export function collectHooks(raw: unknown, home: string = resolveHomeDir()): HooksConfig {
|
|
36
37
|
if (!isRecord(raw)) {
|
|
37
38
|
return {};
|
|
38
39
|
}
|
|
@@ -55,7 +56,7 @@ export function collectHooks(raw: unknown): HooksConfig {
|
|
|
55
56
|
|
|
56
57
|
entries.push({
|
|
57
58
|
command: parsed.data.command,
|
|
58
|
-
...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir) }),
|
|
59
|
+
...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir, home) }),
|
|
59
60
|
...(parsed.data.timeout === undefined ? {} : { timeout: parsed.data.timeout }),
|
|
60
61
|
});
|
|
61
62
|
}
|
|
@@ -68,8 +69,8 @@ export function collectHooks(raw: unknown): HooksConfig {
|
|
|
68
69
|
return hooks;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function normalizeHookDir(dir: string): string {
|
|
72
|
-
const expanded = dir === '~' ?
|
|
72
|
+
function normalizeHookDir(dir: string, home: string): string {
|
|
73
|
+
const expanded = dir === '~' ? home : dir.replace(/^~\//u, `${home}/`);
|
|
73
74
|
const trimmed = expanded.replace(/\/+$/u, '');
|
|
74
75
|
|
|
75
76
|
return trimmed === '' ? '/' : trimmed;
|
package/src/shared/config.ts
CHANGED
|
@@ -176,13 +176,18 @@ const CONFIG_SCHEMA = z.object({
|
|
|
176
176
|
* defaults out. A file that exists but cannot be read or parsed loads as
|
|
177
177
|
* unusable: every default but the targets, which it leaves empty with the
|
|
178
178
|
* problem as the one target error, so nothing runs until the file is fixed.
|
|
179
|
-
*
|
|
179
|
+
* `home` is the directory a `~` in the file expands to, and `state` is the
|
|
180
|
+
* state directory it creates. Never throws.
|
|
180
181
|
*/
|
|
181
|
-
export function loadConfig(
|
|
182
|
+
export function loadConfig(
|
|
183
|
+
file: string = configFile,
|
|
184
|
+
home: string = resolveHomeDir(),
|
|
185
|
+
state: string = stateDir,
|
|
186
|
+
): Config {
|
|
182
187
|
// Every atc process writes under the state directory; a failure here
|
|
183
188
|
// comes back from the first write into it.
|
|
184
189
|
try {
|
|
185
|
-
mkdirSync(
|
|
190
|
+
mkdirSync(state, { recursive: true });
|
|
186
191
|
} catch {}
|
|
187
192
|
|
|
188
193
|
let text: string;
|
|
@@ -213,7 +218,7 @@ export function loadConfig(file: string = configFile): Config {
|
|
|
213
218
|
return buildUnusableConfig('config_malformed', file, 'the file is not valid JSON');
|
|
214
219
|
}
|
|
215
220
|
|
|
216
|
-
return parseConfig(raw, file);
|
|
221
|
+
return parseConfig(raw, file, home);
|
|
217
222
|
}
|
|
218
223
|
|
|
219
224
|
/**
|
|
@@ -292,10 +297,15 @@ export function renderDefaultConfig(): string {
|
|
|
292
297
|
* Parses a user-written config.json's already-decoded JSON value into a
|
|
293
298
|
* Config, applying every default a malformed or absent field falls back to.
|
|
294
299
|
* A root that is not an object leaves the config unusable, with no targets,
|
|
295
|
-
* and `file` is the path its error holds.
|
|
296
|
-
*
|
|
300
|
+
* and `file` is the path its error holds. `home` is the directory a `~` in
|
|
301
|
+
* a directory root or a hook's `dir` expands to. Total: no shape of `raw`
|
|
302
|
+
* throws, so a hand-edited config never stops atc starting.
|
|
297
303
|
*/
|
|
298
|
-
export function parseConfig(
|
|
304
|
+
export function parseConfig(
|
|
305
|
+
raw: unknown,
|
|
306
|
+
file: string = configFile,
|
|
307
|
+
home: string = resolveHomeDir(),
|
|
308
|
+
): Config {
|
|
299
309
|
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
300
310
|
return buildUnusableConfig(
|
|
301
311
|
'config_malformed',
|
|
@@ -320,7 +330,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
320
330
|
return buildUnusableConfig('config_malformed', file, formatMixedAgentKeys(present));
|
|
321
331
|
}
|
|
322
332
|
|
|
323
|
-
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
333
|
+
const dirs = { roots: collectDirRoots(parsed.data.dirs, home) };
|
|
324
334
|
const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
|
|
325
335
|
const authProfiles = collectAuthProfiles(parsed.data.authProfiles);
|
|
326
336
|
|
|
@@ -340,7 +350,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
340
350
|
authProfiles.profiles,
|
|
341
351
|
);
|
|
342
352
|
|
|
343
|
-
const hooks = collectHooks(parsed.data.hooks);
|
|
353
|
+
const hooks = collectHooks(parsed.data.hooks, home);
|
|
344
354
|
const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
|
|
345
355
|
const principals = collectPrincipals(parsed.data.principals);
|
|
346
356
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The time and the timers a module reads, so a test can step them. `now`
|
|
3
|
+
* returns milliseconds since the epoch; `schedule` runs the callback once
|
|
4
|
+
* after the delay and returns a function that cancels it, which does
|
|
5
|
+
* nothing once the callback has run.
|
|
6
|
+
*/
|
|
7
|
+
export interface Clock {
|
|
8
|
+
readonly now: () => number;
|
|
9
|
+
readonly schedule: (callback: () => void, delayMs: number) => () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The wall clock and the runtime's own timers.
|
|
14
|
+
*/
|
|
15
|
+
export const systemClock: Clock = {
|
|
16
|
+
now: () => Date.now(),
|
|
17
|
+
schedule: (callback, delayMs) => {
|
|
18
|
+
const timer = setTimeout(callback, delayMs);
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -89,7 +89,7 @@ export async function collectGitHubRepos(
|
|
|
89
89
|
|
|
90
90
|
const timeoutMs = request.timeoutMs ?? GH_TIMEOUT_MS;
|
|
91
91
|
|
|
92
|
-
const listed = await runGH(bin, timeoutMs, [
|
|
92
|
+
const listed = await runGH(bin, AbortSignal.timeout(timeoutMs), [
|
|
93
93
|
'repo',
|
|
94
94
|
'list',
|
|
95
95
|
...(request.owner === null ? [] : [request.owner]),
|
|
@@ -5,7 +5,7 @@ import { runGH } from './run-gh';
|
|
|
5
5
|
* so, else `https`, which a gh that fails or takes too long also gives.
|
|
6
6
|
*/
|
|
7
7
|
export async function readGitProtocol(bin: string, timeoutMs: number): Promise<'https' | 'ssh'> {
|
|
8
|
-
const run = await runGH(bin, timeoutMs, ['config', 'get', 'git_protocol']);
|
|
8
|
+
const run = await runGH(bin, AbortSignal.timeout(timeoutMs), ['config', 'get', 'git_protocol']);
|
|
9
9
|
|
|
10
10
|
return run.exitCode === 0 && run.stdout.trim() === 'ssh' ? 'ssh' : 'https';
|
|
11
11
|
}
|
|
@@ -6,14 +6,14 @@ export interface GHRun {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Runs gh with arguments
|
|
10
|
-
*
|
|
9
|
+
* Runs gh with arguments until it exits or the signal aborts, killing its
|
|
10
|
+
* whole process group on abort, so a wrapper or extension leaves no process
|
|
11
11
|
* behind. gh reads its own config and the host's environment for its
|
|
12
12
|
* token, and is kept from prompting, colouring, or checking for updates.
|
|
13
13
|
*/
|
|
14
14
|
export async function runGH(
|
|
15
15
|
bin: string,
|
|
16
|
-
|
|
16
|
+
signal: Readonly<AbortSignal>,
|
|
17
17
|
args: readonly string[],
|
|
18
18
|
): Promise<GHRun> {
|
|
19
19
|
const proc = Bun.spawn([bin, ...args], {
|
|
@@ -40,15 +40,21 @@ export async function runGH(
|
|
|
40
40
|
proc.exited,
|
|
41
41
|
]);
|
|
42
42
|
|
|
43
|
-
const
|
|
43
|
+
const aborted = Promise.withResolvers<null>();
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
}
|
|
45
|
+
const onAbort = () => {
|
|
46
|
+
aborted.resolve(null);
|
|
47
|
+
};
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
if (signal.aborted) {
|
|
50
|
+
onAbort();
|
|
51
|
+
} else {
|
|
52
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const settled = await Promise.race([finished, aborted.promise]);
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
signal.removeEventListener('abort', onAbort);
|
|
52
58
|
|
|
53
59
|
if (settled === null) {
|
|
54
60
|
try {
|
package/src/store/state-store.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { Database } from 'bun:sqlite';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
CompiledQuery,
|
|
7
|
+
Expression,
|
|
8
|
+
ExpressionBuilder,
|
|
9
|
+
LogEvent,
|
|
10
|
+
SqlBool,
|
|
11
|
+
Transaction,
|
|
12
|
+
} from 'kysely';
|
|
6
13
|
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
7
14
|
import { DaemonError } from '../protocol/daemon-error';
|
|
8
15
|
import type { HookEvent } from '../protocol/hook-event';
|
|
@@ -129,6 +136,9 @@ export class StateStore {
|
|
|
129
136
|
// migration that created session ownership.
|
|
130
137
|
readonly daemonID: DaemonID;
|
|
131
138
|
|
|
139
|
+
// The close under way or done, which every later close waits on.
|
|
140
|
+
private stopping: Promise<void> | null = null;
|
|
141
|
+
|
|
132
142
|
private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
|
|
133
143
|
this.sqlite = sqlite;
|
|
134
144
|
this.db = db;
|
|
@@ -136,8 +146,13 @@ export class StateStore {
|
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
// Migrations run statements that cannot happen inside a constructor, so
|
|
139
|
-
// opening a store is this factory instead of `new`.
|
|
140
|
-
|
|
149
|
+
// opening a store is this factory instead of `new`. A caller that passes
|
|
150
|
+
// a query listener receives each statement the store runs once it ran.
|
|
151
|
+
static async open(
|
|
152
|
+
dbPath: string,
|
|
153
|
+
legacyFleetPath?: string,
|
|
154
|
+
onQuery?: (query: CompiledQuery) => void,
|
|
155
|
+
): Promise<StateStore> {
|
|
141
156
|
const sqlite = new Database(dbPath, { create: true });
|
|
142
157
|
|
|
143
158
|
sqlite.run('PRAGMA journal_mode = WAL;');
|
|
@@ -149,6 +164,15 @@ export class StateStore {
|
|
|
149
164
|
createIntrospector: (kysely) => new SqliteIntrospector(kysely),
|
|
150
165
|
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
151
166
|
},
|
|
167
|
+
...(onQuery === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: {
|
|
170
|
+
log: (event: LogEvent) => {
|
|
171
|
+
if (event.level === 'query') {
|
|
172
|
+
onQuery(event.query);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
152
176
|
});
|
|
153
177
|
|
|
154
178
|
await runMigrations(db);
|
|
@@ -414,7 +438,13 @@ export class StateStore {
|
|
|
414
438
|
});
|
|
415
439
|
}
|
|
416
440
|
|
|
417
|
-
|
|
441
|
+
// Records a hook event in the trail, stamped with the given time in epoch
|
|
442
|
+
// milliseconds, or the wall clock's when absent.
|
|
443
|
+
async recordEvent(
|
|
444
|
+
e: HookEvent,
|
|
445
|
+
ev: Readonly<AdapterEvent> | null = null,
|
|
446
|
+
at: number = Date.now(),
|
|
447
|
+
): Promise<void> {
|
|
418
448
|
const rawMessage = e.payload['message'];
|
|
419
449
|
const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
|
|
420
450
|
const message = typeof rawMessage === 'string' ? rawMessage : null;
|
|
@@ -424,7 +454,7 @@ export class StateStore {
|
|
|
424
454
|
await this.db
|
|
425
455
|
.insertInto('events')
|
|
426
456
|
.values({
|
|
427
|
-
ts: new Date().toISOString(),
|
|
457
|
+
ts: new Date(at).toISOString(),
|
|
428
458
|
atc_id: atcID,
|
|
429
459
|
event: e.event,
|
|
430
460
|
message,
|
|
@@ -1132,10 +1162,30 @@ export class StateStore {
|
|
|
1132
1162
|
.execute();
|
|
1133
1163
|
}
|
|
1134
1164
|
|
|
1165
|
+
// A second close, or a disposal after one, waits on the first and closes
|
|
1166
|
+
// nothing again; a close that fails is forgotten, so the next one retries.
|
|
1135
1167
|
async stop(): Promise<void> {
|
|
1136
|
-
|
|
1168
|
+
this.stopping ??= (async () => {
|
|
1169
|
+
await this.db.destroy();
|
|
1170
|
+
|
|
1171
|
+
this.sqlite.close();
|
|
1172
|
+
})();
|
|
1173
|
+
|
|
1174
|
+
const current = this.stopping;
|
|
1175
|
+
|
|
1176
|
+
try {
|
|
1177
|
+
await current;
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (this.stopping === current) {
|
|
1180
|
+
this.stopping = null;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
throw error;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1137
1186
|
|
|
1138
|
-
|
|
1187
|
+
[Symbol.asyncDispose](): Promise<void> {
|
|
1188
|
+
return this.stop();
|
|
1139
1189
|
}
|
|
1140
1190
|
|
|
1141
1191
|
private async adoptLegacyFleet(legacyFleetPath: string): Promise<void> {
|
package/src/tap.ts
CHANGED
|
@@ -19,6 +19,24 @@ interface InboxMessage {
|
|
|
19
19
|
readonly sentAt: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Where the tap prints and how it exits: stdout, stderr, and the process by
|
|
23
|
+
// default.
|
|
24
|
+
interface TapIO {
|
|
25
|
+
readonly writeStdout: (text: string) => Promise<unknown>;
|
|
26
|
+
readonly printError: (line: string) => void;
|
|
27
|
+
readonly exit: (code: number) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PROCESS_IO: TapIO = {
|
|
31
|
+
writeStdout: (text) => Bun.write(Bun.stdout, text),
|
|
32
|
+
printError: (line) => {
|
|
33
|
+
console.error(line);
|
|
34
|
+
},
|
|
35
|
+
exit: (code) => {
|
|
36
|
+
process.exit(code);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
22
40
|
/**
|
|
23
41
|
* Streams one session's inbox to stdout, one NDJSON line per message, and
|
|
24
42
|
* acks each message once its line is written. It talks to the daemon that
|
|
@@ -26,15 +44,20 @@ interface InboxMessage {
|
|
|
26
44
|
* serves, so a restart from here would take the whole fleet down. Exits 1
|
|
27
45
|
* with a hint when no daemon listens or the session cannot be tapped, and
|
|
28
46
|
* 0 once the daemon closes the connection or ends the subscription because
|
|
29
|
-
* another tap replaced it or the session was removed.
|
|
47
|
+
* another tap replaced it or the session was removed. It dials the daemon
|
|
48
|
+
* at `socketPath`, the running daemon's socket by default.
|
|
30
49
|
*/
|
|
31
|
-
export async function runTap(
|
|
50
|
+
export async function runTap(
|
|
51
|
+
session: string,
|
|
52
|
+
socketPath: string = daemonSocketPath,
|
|
53
|
+
io: TapIO = PROCESS_IO,
|
|
54
|
+
): Promise<void> {
|
|
32
55
|
const bridge = process.env['ATC_SOCKET'];
|
|
33
56
|
|
|
34
57
|
// Inside a remote host the daemon's own socket is out of reach, and the
|
|
35
58
|
// session bridge serves the tap instead.
|
|
36
59
|
if (process.env['ATC_BRIDGE'] === '1' && bridge !== undefined && bridge !== '') {
|
|
37
|
-
await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '');
|
|
60
|
+
await runBridgeTap(bridge, process.env['ATC_OUTBOX'] ?? '', io);
|
|
38
61
|
|
|
39
62
|
return;
|
|
40
63
|
}
|
|
@@ -42,10 +65,12 @@ export async function runTap(session: string): Promise<void> {
|
|
|
42
65
|
let client: DaemonClient;
|
|
43
66
|
|
|
44
67
|
try {
|
|
45
|
-
client = await DaemonClient.open(
|
|
68
|
+
client = await DaemonClient.open(socketPath);
|
|
46
69
|
} catch {
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
io.printError(`atc tap: no daemon at ${socketPath} — start atc first`);
|
|
71
|
+
io.exit(1);
|
|
72
|
+
|
|
73
|
+
return;
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
const closed = Promise.withResolvers<void>();
|
|
@@ -83,7 +108,7 @@ export async function runTap(session: string): Promise<void> {
|
|
|
83
108
|
|
|
84
109
|
written = (async () => {
|
|
85
110
|
await previous;
|
|
86
|
-
await ackInboxMessage(client, session, msg);
|
|
111
|
+
await ackInboxMessage(client, session, msg, io);
|
|
87
112
|
})();
|
|
88
113
|
};
|
|
89
114
|
|
|
@@ -91,15 +116,18 @@ export async function runTap(session: string): Promise<void> {
|
|
|
91
116
|
await client.sendHello(getBuild());
|
|
92
117
|
await client.sendRequest('session.tap', { session });
|
|
93
118
|
} catch (error) {
|
|
94
|
-
|
|
119
|
+
io.printError(`atc tap: ${formatError(error)}`);
|
|
95
120
|
client.stop();
|
|
96
|
-
|
|
121
|
+
io.exit(1);
|
|
122
|
+
|
|
123
|
+
return;
|
|
97
124
|
}
|
|
98
125
|
|
|
99
126
|
await closed.promise;
|
|
100
127
|
await written;
|
|
101
128
|
|
|
102
|
-
|
|
129
|
+
client.stop();
|
|
130
|
+
io.exit(0);
|
|
103
131
|
}
|
|
104
132
|
|
|
105
133
|
interface RequestSender {
|
|
@@ -110,19 +138,22 @@ async function ackInboxMessage(
|
|
|
110
138
|
client: RequestSender,
|
|
111
139
|
session: string,
|
|
112
140
|
msg: InboxMessage,
|
|
141
|
+
io: TapIO,
|
|
113
142
|
): Promise<void> {
|
|
114
143
|
const line = `${JSON.stringify({ id: msg.message, from: msg.from, text: msg.text, sentAt: msg.sentAt })}\n`;
|
|
115
144
|
|
|
116
145
|
try {
|
|
117
|
-
await
|
|
146
|
+
await io.writeStdout(line);
|
|
118
147
|
} catch {
|
|
119
|
-
|
|
148
|
+
io.exit(1);
|
|
149
|
+
|
|
150
|
+
return;
|
|
120
151
|
}
|
|
121
152
|
|
|
122
153
|
try {
|
|
123
154
|
await client.sendRequest('message.ack', { session, message: msg.message });
|
|
124
155
|
} catch (error) {
|
|
125
|
-
|
|
156
|
+
io.printError(`atc tap: ${formatError(error)}`);
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
159
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An agent adapter for daemon tests: id `claude`, a spawn that runs
|
|
5
|
+
* `sleep 30` so the session stays alive with no agent CLI behind it, every
|
|
6
|
+
* hook read as a heartbeat, no name, no resume command, no headless runner,
|
|
7
|
+
* no screen detector, and no inbox messages. Every optional member is
|
|
8
|
+
* absent, so the daemon takes the stand-in path for each one, and
|
|
9
|
+
* `agents.list` reports the adapter as not installed. An override replaces
|
|
10
|
+
* the member it names.
|
|
11
|
+
*/
|
|
12
|
+
export function buildMockAgentAdapter(overrides: Partial<AgentAdapter> = {}): AgentAdapter {
|
|
13
|
+
return {
|
|
14
|
+
id: 'claude',
|
|
15
|
+
headlessRunner: null,
|
|
16
|
+
screenDetector: null,
|
|
17
|
+
takesMessages: false,
|
|
18
|
+
planSpawn: () => ({ bin: 'sleep', args: ['30'] }),
|
|
19
|
+
normalizeHook: () => ({ kind: 'heartbeat' }),
|
|
20
|
+
loadName: () => Promise.resolve(null),
|
|
21
|
+
canResume: () => true,
|
|
22
|
+
buildResumeCommand: () => null,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { AgentEntry } from '../shared/collect-agents';
|
|
3
|
+
import { mergeDeep } from './merge-deep';
|
|
4
|
+
import type { MockOverrides } from './mock-overrides';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The fields the defaults set; every other field is absent until an
|
|
8
|
+
* override gives it whole.
|
|
9
|
+
*/
|
|
10
|
+
type DefaultedKey = 'id' | 'kind' | 'label' | 'mark' | 'bin' | 'args' | 'env';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A registry entry for a plain Claude agent: kind `claude`, no arguments,
|
|
14
|
+
* no environment, and none of the gateway fields. The id, label, mark, and
|
|
15
|
+
* binary are arbitrary. Overrides merge into fresh defaults at every depth.
|
|
16
|
+
*/
|
|
17
|
+
export function buildMockAgentEntry(
|
|
18
|
+
overrides: MockOverrides<AgentEntry, DefaultedKey> = {},
|
|
19
|
+
): AgentEntry {
|
|
20
|
+
return mergeDeep<AgentEntry, DefaultedKey>(
|
|
21
|
+
{
|
|
22
|
+
id: faker.string.alpha({ length: 8, casing: 'lower' }),
|
|
23
|
+
kind: 'claude',
|
|
24
|
+
label: faker.company.name(),
|
|
25
|
+
mark: faker.string.alpha({ length: 1, casing: 'lower' }),
|
|
26
|
+
bin: faker.system.filePath(),
|
|
27
|
+
args: [],
|
|
28
|
+
env: {},
|
|
29
|
+
},
|
|
30
|
+
overrides,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { AuthBinding } from '../daemon/build-auth-binding';
|
|
3
|
+
import { toAgentID } from '../shared/to-agent-id';
|
|
4
|
+
import { mergeDeep } from './merge-deep';
|
|
5
|
+
import type { MockOverrides } from './mock-overrides';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A runtime auth binding for agent `glm` at an arbitrary endpoint, reaching
|
|
9
|
+
* no profile and needing no secret, with no placeholder or profile
|
|
10
|
+
* variables and an arbitrary hash. Overrides merge into fresh defaults at
|
|
11
|
+
* every depth, and a list replaces its default whole.
|
|
12
|
+
*/
|
|
13
|
+
export function buildMockAuthBinding(
|
|
14
|
+
overrides: MockOverrides<AuthBinding, keyof AuthBinding> = {},
|
|
15
|
+
): AuthBinding {
|
|
16
|
+
return mergeDeep<AuthBinding>(
|
|
17
|
+
{
|
|
18
|
+
agent: toAgentID('glm'),
|
|
19
|
+
baseURL: faker.internet.url(),
|
|
20
|
+
profiles: [],
|
|
21
|
+
secrets: [],
|
|
22
|
+
placeholderEnv: {},
|
|
23
|
+
profileEnv: {},
|
|
24
|
+
hash: faker.string.hexadecimal({ length: 64, casing: 'lower', prefix: '' }),
|
|
25
|
+
},
|
|
26
|
+
overrides,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import type { AuthProfile } from '../shared/collect-auth-profiles';
|
|
3
|
+
import { mergeDeep } from './merge-deep';
|
|
4
|
+
import type { MockOverrides } from './mock-overrides';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The auth profile kinds that inject a header for a broker host.
|
|
8
|
+
*/
|
|
9
|
+
type HeaderAuthProfile = Extract<AuthProfile, { readonly host: string }>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The auth profile kind that signs in to GitHub.
|
|
13
|
+
*/
|
|
14
|
+
type GitHubAuthProfile = Extract<AuthProfile, { readonly kind: 'github' }>;
|
|
15
|
+
|
|
16
|
+
type HeaderOverrides = MockOverrides<HeaderAuthProfile, keyof HeaderAuthProfile>;
|
|
17
|
+
|
|
18
|
+
type GitHubOverrides = MockOverrides<GitHubAuthProfile, keyof GitHubAuthProfile> & {
|
|
19
|
+
readonly kind: 'github';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An auth profile of kind `custom` that sends its secret as a bearer token
|
|
24
|
+
* in a header, or, for an override of kind `github`, a GitHub profile.
|
|
25
|
+
* Either has no environment and no dependencies, and an arbitrary name and
|
|
26
|
+
* secret; a custom profile's host and header name are arbitrary too.
|
|
27
|
+
* Overrides merge into fresh defaults at every depth.
|
|
28
|
+
*/
|
|
29
|
+
export function buildMockAuthProfile(overrides?: HeaderOverrides): HeaderAuthProfile;
|
|
30
|
+
export function buildMockAuthProfile(overrides: GitHubOverrides): GitHubAuthProfile;
|
|
31
|
+
|
|
32
|
+
export function buildMockAuthProfile(
|
|
33
|
+
overrides: HeaderOverrides | GitHubOverrides = {},
|
|
34
|
+
): AuthProfile {
|
|
35
|
+
const name = faker.string.alpha({ length: 8, casing: 'lower' });
|
|
36
|
+
const secret = faker.string.alpha({ length: 8, casing: 'lower' });
|
|
37
|
+
|
|
38
|
+
return isGitHubOverrides(overrides)
|
|
39
|
+
? mergeDeep<GitHubAuthProfile>(
|
|
40
|
+
{ name, secret, kind: 'github', env: {}, dependencies: [] },
|
|
41
|
+
overrides,
|
|
42
|
+
)
|
|
43
|
+
: mergeDeep<HeaderAuthProfile>(
|
|
44
|
+
{
|
|
45
|
+
name,
|
|
46
|
+
secret,
|
|
47
|
+
kind: 'custom',
|
|
48
|
+
host: faker.internet.domainName(),
|
|
49
|
+
header: `x-${faker.string.alpha({ length: 6, casing: 'lower' })}-key`,
|
|
50
|
+
scheme: 'bearer',
|
|
51
|
+
env: {},
|
|
52
|
+
dependencies: [],
|
|
53
|
+
},
|
|
54
|
+
overrides,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isGitHubOverrides(
|
|
59
|
+
overrides: HeaderOverrides | GitHubOverrides,
|
|
60
|
+
): overrides is GitHubOverrides {
|
|
61
|
+
return overrides.kind === 'github';
|
|
62
|
+
}
|