@zgeoff/atc 3.4.2 → 3.5.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 +1 -1
- package/src/agents/agent-adapter.ts +4 -0
- package/src/agents/build-claude-query-options.ts +1 -0
- package/src/agents/build-headless-env.ts +4 -0
- package/src/client/daemon-client.ts +6 -1
- package/src/collect-restart-env.ts +6 -3
- package/src/daemon/build-scoped-context.ts +2 -0
- package/src/daemon/check-session-scope.ts +235 -0
- package/src/daemon/daemon-connection.ts +74 -0
- package/src/daemon/daemon-context.ts +21 -0
- package/src/daemon/daemon.ts +21 -0
- package/src/daemon/find-host-branch.ts +28 -0
- package/src/daemon/materialize-workspace.ts +6 -1
- package/src/daemon/merge-recorded-scope.ts +50 -0
- package/src/daemon/published-records.ts +269 -0
- package/src/daemon/request-access-classes.ts +1 -0
- package/src/daemon/run-host-git.ts +52 -0
- package/src/daemon/sessions.ts +185 -53
- package/src/daemon/start-headless-turn.ts +3 -0
- package/src/federation/id-rules.ts +11 -2
- package/src/mcp/mcp-tools.ts +50 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +14 -0
- package/src/protocol/daemon-features.ts +5 -0
- package/src/protocol/parse-declared-scope.ts +158 -0
- package/src/protocol/protocol.ts +1 -0
- package/src/protocol/published-record.ts +62 -0
- package/src/protocol/request-param-schemas.ts +16 -0
- package/src/store/parse-published-record.ts +59 -0
- package/src/store/run-migrations.ts +23 -0
- package/src/store/state-store.ts +32 -0
- package/src/test-utils/build-mock-published-record.ts +39 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
- package/src/test-utils/build-stub-daemon-context.ts +2 -0
- package/src/test-utils/build-stub-daemon-requests.ts +3 -3
- package/src/test-utils/build-stub-harness-relay.ts +2 -2
- package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
- package/src/test-utils/build-stub-host-hold.ts +7 -6
- package/src/test-utils/build-stub-launch-admission.ts +20 -0
- package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
- package/src/test-utils/build-stub-terminal.ts +3 -3
- package/src/test-utils/create-git-fixture.ts +2 -2
- package/src/test-utils/create-stub-imp-port.ts +37 -37
- package/src/test-utils/create-stub-systemd.ts +6 -6
- package/src/test-utils/isolate-home.ts +8 -5
- package/src/test-utils/read-query-plan.ts +10 -6
- package/src/test-utils/register-test-cleanup.ts +10 -10
- package/src/test-utils/setup-mcp-home.ts +7 -6
- package/src/test-utils/setup-mcp-http.ts +6 -6
- package/src/test-utils/setup-temp-dir.ts +4 -10
- package/src/test-utils/start-cut-proxy.ts +2 -4
- package/src/test-utils/start-daemon-process.ts +13 -13
- package/src/test-utils/start-git-http-server.ts +2 -4
- package/src/test-utils/start-mcp-stdio.ts +5 -5
- package/src/test-utils/start-stub-answering-listener.ts +3 -3
- package/src/test-utils/start-stub-closing-listener.ts +3 -3
- package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
- package/src/test-utils/start-stub-impd-info.ts +3 -7
- package/src/test-utils/start-stub-impd.ts +3 -5
- package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
- package/src/test-utils/start-stub-mcp-server.ts +3 -5
- package/src/test-utils/start-stub-recording-listener.ts +3 -3
- package/src/test-utils/start-stub-refusing-listener.ts +3 -3
- package/src/test-utils/start-stub-reporter-socket.ts +3 -9
- package/src/test-utils/start-stub-restore-daemon.ts +2 -7
- package/src/test-utils/start-stub-session-bridge.ts +3 -9
- package/src/test-utils/start-stub-silent-server.ts +3 -6
- package/src/test-utils/start-stub-source-daemon.ts +2 -2
- package/src/test-utils/start-stub-stalled-client.ts +3 -3
- package/src/test-utils/start-stub-stalled-listener.ts +3 -3
- package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
- package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
- package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
- package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
- package/src/test-utils/start-test-daemon.ts +62 -18
- package/src/test-utils/start-tui-harness.ts +17 -10
- package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
- package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
|
@@ -43,12 +43,10 @@ const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
|
43
43
|
|
|
44
44
|
// A hold on the commands whose argv holds its text: entered resolves with
|
|
45
45
|
// the argv of the first command it holds, and stop lets every command it
|
|
46
|
-
// holds run.
|
|
47
|
-
// `using` after its daemon lets the daemon stop even when the test fails.
|
|
46
|
+
// holds run.
|
|
48
47
|
interface StubCommandHold {
|
|
49
48
|
readonly entered: Promise<string>;
|
|
50
49
|
readonly stop: () => void;
|
|
51
|
-
readonly [Symbol.dispose]: () => void;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
/**
|
|
@@ -475,7 +473,7 @@ class StubImpPort implements ImpPort {
|
|
|
475
473
|
}
|
|
476
474
|
|
|
477
475
|
for (const proc of imp.sessions.values()) {
|
|
478
|
-
proc.connection?.
|
|
476
|
+
proc.connection?.stop({ kind: 'closed', reason: 'imp destroyed', closeCode: 1000 });
|
|
479
477
|
proc.connection = null;
|
|
480
478
|
proc.exited ??= { code: null };
|
|
481
479
|
|
|
@@ -504,7 +502,7 @@ class StubImpPort implements ImpPort {
|
|
|
504
502
|
sent: 0,
|
|
505
503
|
finished: false,
|
|
506
504
|
process: null,
|
|
507
|
-
|
|
505
|
+
stop: (result) => {
|
|
508
506
|
if (connection.finished) {
|
|
509
507
|
return;
|
|
510
508
|
}
|
|
@@ -523,7 +521,7 @@ class StubImpPort implements ImpPort {
|
|
|
523
521
|
// lets it go; a closed gate sends nothing.
|
|
524
522
|
const sendToImpd = () => {
|
|
525
523
|
if (gate !== undefined && !tryPassGate(gate)) {
|
|
526
|
-
connection.
|
|
524
|
+
connection.stop({ kind: 'closed', reason: 'closed before sending', closeCode: 1000 });
|
|
527
525
|
|
|
528
526
|
return;
|
|
529
527
|
}
|
|
@@ -548,7 +546,7 @@ class StubImpPort implements ImpPort {
|
|
|
548
546
|
this.upgradeFailures -= 1;
|
|
549
547
|
|
|
550
548
|
setTimeout(() => {
|
|
551
|
-
connection.
|
|
549
|
+
connection.stop({ kind: 'unreachable', detail: 'the upgrade failed' });
|
|
552
550
|
}, 0);
|
|
553
551
|
} else if (this.upgrades === null) {
|
|
554
552
|
sendToImpd();
|
|
@@ -575,7 +573,7 @@ class StubImpPort implements ImpPort {
|
|
|
575
573
|
}
|
|
576
574
|
},
|
|
577
575
|
close: () => {
|
|
578
|
-
connection.
|
|
576
|
+
connection.stop({ kind: 'closed', reason: 'closed by the client', closeCode: 1000 });
|
|
579
577
|
},
|
|
580
578
|
};
|
|
581
579
|
}
|
|
@@ -852,7 +850,7 @@ class StubImpPort implements ImpPort {
|
|
|
852
850
|
stopConnection(name: string, session: string, closeCode: number): void {
|
|
853
851
|
const proc = this.getImp(name).sessions.get(session);
|
|
854
852
|
|
|
855
|
-
proc?.connection?.
|
|
853
|
+
proc?.connection?.stop({ kind: 'closed', reason: `code ${closeCode}`, closeCode });
|
|
856
854
|
}
|
|
857
855
|
|
|
858
856
|
/**
|
|
@@ -1067,7 +1065,7 @@ class StubImpPort implements ImpPort {
|
|
|
1067
1065
|
hold.done.resolve();
|
|
1068
1066
|
};
|
|
1069
1067
|
|
|
1070
|
-
return { entered: hold.entered.promise, stop
|
|
1068
|
+
return { entered: hold.entered.promise, stop };
|
|
1071
1069
|
}
|
|
1072
1070
|
|
|
1073
1071
|
// How many commands wait for their command hold to stop.
|
|
@@ -1172,7 +1170,10 @@ class StubImpPort implements ImpPort {
|
|
|
1172
1170
|
return proc.end;
|
|
1173
1171
|
}
|
|
1174
1172
|
|
|
1175
|
-
|
|
1173
|
+
// Kills every process and stops every forward the stand-in holds. It runs
|
|
1174
|
+
// once the current test finishes; calling it sooner runs it then, and a
|
|
1175
|
+
// second call does nothing.
|
|
1176
|
+
readonly stop: () => void = registerTestCleanup(() => {
|
|
1176
1177
|
this.stopCommandHold();
|
|
1177
1178
|
|
|
1178
1179
|
for (const imp of this.imps.values()) {
|
|
@@ -1187,7 +1188,7 @@ class StubImpPort implements ImpPort {
|
|
|
1187
1188
|
}
|
|
1188
1189
|
|
|
1189
1190
|
this.imps.clear();
|
|
1190
|
-
}
|
|
1191
|
+
});
|
|
1191
1192
|
|
|
1192
1193
|
// impd's refusal of a grant or revoke: the scope and pattern checks run
|
|
1193
1194
|
// before anything is looked up, then a missing imp or secret.
|
|
@@ -1278,13 +1279,13 @@ class StubImpPort implements ImpPort {
|
|
|
1278
1279
|
|
|
1279
1280
|
for (const proc of imp.sessions.values()) {
|
|
1280
1281
|
tryKill(proc.pty, 'SIGSTOP');
|
|
1281
|
-
proc.connection?.
|
|
1282
|
+
proc.connection?.stop({ kind: 'detached', reason: 'lost', offset: proc.end });
|
|
1282
1283
|
}
|
|
1283
1284
|
}
|
|
1284
1285
|
|
|
1285
1286
|
private bootCold(imp: StubImp, cause: ColdBootCause): void {
|
|
1286
1287
|
for (const proc of imp.sessions.values()) {
|
|
1287
|
-
proc.connection?.
|
|
1288
|
+
proc.connection?.stop({ kind: 'detached', reason: 'lost', offset: proc.end });
|
|
1288
1289
|
proc.ended = true;
|
|
1289
1290
|
|
|
1290
1291
|
tryKill(proc.pty, 'SIGCONT');
|
|
@@ -1311,7 +1312,7 @@ class StubImpPort implements ImpPort {
|
|
|
1311
1312
|
if (this.drops.count > 0) {
|
|
1312
1313
|
this.drops.count -= 1;
|
|
1313
1314
|
|
|
1314
|
-
connection.
|
|
1315
|
+
connection.stop({
|
|
1315
1316
|
kind: 'closed',
|
|
1316
1317
|
reason: `code ${this.drops.closeCode}`,
|
|
1317
1318
|
closeCode: this.drops.closeCode,
|
|
@@ -1325,7 +1326,7 @@ class StubImpPort implements ImpPort {
|
|
|
1325
1326
|
if (failure !== null) {
|
|
1326
1327
|
this.nextFailure = null;
|
|
1327
1328
|
|
|
1328
|
-
connection.
|
|
1329
|
+
connection.stop({
|
|
1329
1330
|
kind: 'failed',
|
|
1330
1331
|
code: failure.code,
|
|
1331
1332
|
message: failure.message ?? `impd refused the session (${failure.code})`,
|
|
@@ -1338,7 +1339,7 @@ class StubImpPort implements ImpPort {
|
|
|
1338
1339
|
const imp = this.imps.get(request.name);
|
|
1339
1340
|
|
|
1340
1341
|
if (imp === undefined) {
|
|
1341
|
-
connection.
|
|
1342
|
+
connection.stop({
|
|
1342
1343
|
kind: 'failed',
|
|
1343
1344
|
code: 'NOT_FOUND',
|
|
1344
1345
|
message: `no imp ${request.name}`,
|
|
@@ -1349,7 +1350,7 @@ class StubImpPort implements ImpPort {
|
|
|
1349
1350
|
}
|
|
1350
1351
|
|
|
1351
1352
|
if (imp.state !== 'running' && request.kind === 'attach' && !request.wake) {
|
|
1352
|
-
connection.
|
|
1353
|
+
connection.stop({
|
|
1353
1354
|
kind: 'failed',
|
|
1354
1355
|
code: 'INVALID_STATE',
|
|
1355
1356
|
message: `imp ${imp.name} is ${imp.state}`,
|
|
@@ -1369,7 +1370,7 @@ class StubImpPort implements ImpPort {
|
|
|
1369
1370
|
const brokerProblem = this.findBrokerProblem(imp, request, running);
|
|
1370
1371
|
|
|
1371
1372
|
if (brokerProblem !== null) {
|
|
1372
|
-
connection.
|
|
1373
|
+
connection.stop({
|
|
1373
1374
|
kind: 'failed',
|
|
1374
1375
|
code: 'PRECONDITION_FAILED',
|
|
1375
1376
|
message: `the broker is not ready in imp ${imp.name}`,
|
|
@@ -1382,7 +1383,7 @@ class StubImpPort implements ImpPort {
|
|
|
1382
1383
|
if (running === undefined && request.kind === 'attach') {
|
|
1383
1384
|
const previous = imp.previous.get(request.session);
|
|
1384
1385
|
|
|
1385
|
-
connection.
|
|
1386
|
+
connection.stop({
|
|
1386
1387
|
kind: 'failed',
|
|
1387
1388
|
code: 'NO_SESSION',
|
|
1388
1389
|
message: `no session ${request.session}`,
|
|
@@ -1514,7 +1515,7 @@ class StubImpPort implements ImpPort {
|
|
|
1514
1515
|
// delivers it.
|
|
1515
1516
|
if (connection !== null) {
|
|
1516
1517
|
imp.sessions.delete(request.session);
|
|
1517
|
-
connection.
|
|
1518
|
+
connection.stop({ kind: 'exit', code: exitCode, signal: null, offset: proc.end });
|
|
1518
1519
|
}
|
|
1519
1520
|
});
|
|
1520
1521
|
|
|
@@ -1543,7 +1544,7 @@ class StubImpPort implements ImpPort {
|
|
|
1543
1544
|
firstOffset: bufferStart,
|
|
1544
1545
|
};
|
|
1545
1546
|
} else if (resumeFrom.offset > proc.end) {
|
|
1546
|
-
connection.
|
|
1547
|
+
connection.stop({
|
|
1547
1548
|
kind: 'failed',
|
|
1548
1549
|
code: 'INVALID_RESUME',
|
|
1549
1550
|
message: 'the resume offset is past the end',
|
|
@@ -1566,7 +1567,7 @@ class StubImpPort implements ImpPort {
|
|
|
1566
1567
|
prelude = PRELUDE;
|
|
1567
1568
|
}
|
|
1568
1569
|
|
|
1569
|
-
proc.connection?.
|
|
1570
|
+
proc.connection?.stop({
|
|
1570
1571
|
kind: 'detached',
|
|
1571
1572
|
reason: 'taken_over',
|
|
1572
1573
|
offset: proc.connection.sent,
|
|
@@ -1619,8 +1620,13 @@ class StubImpPort implements ImpPort {
|
|
|
1619
1620
|
}
|
|
1620
1621
|
|
|
1621
1622
|
if (proc.exited !== null) {
|
|
1622
|
-
|
|
1623
|
-
|
|
1623
|
+
const name = findSessionName(imp, proc);
|
|
1624
|
+
|
|
1625
|
+
if (name !== undefined) {
|
|
1626
|
+
imp.sessions.delete(name);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
connection.stop({ kind: 'exit', code: proc.exited.code, signal: null, offset: proc.end });
|
|
1624
1630
|
|
|
1625
1631
|
return;
|
|
1626
1632
|
}
|
|
@@ -1650,16 +1656,10 @@ class StubImpPort implements ImpPort {
|
|
|
1650
1656
|
* broker variable, and when it would join a process that started without
|
|
1651
1657
|
* the broker required. Once the current test finishes, the stand-in kills
|
|
1652
1658
|
* every process and stops every forward it holds, so it must be created inside
|
|
1653
|
-
* a test;
|
|
1659
|
+
* a test; `stop` does so sooner, and a second stop does nothing.
|
|
1654
1660
|
*/
|
|
1655
1661
|
export function createStubImpPort(principal = 'token:atc'): StubImpPort {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
registerTestCleanup(() => {
|
|
1659
|
-
port[Symbol.dispose]();
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
return port;
|
|
1662
|
+
return new StubImpPort(principal);
|
|
1663
1663
|
}
|
|
1664
1664
|
|
|
1665
1665
|
interface StubLease {
|
|
@@ -1703,7 +1703,7 @@ interface StubConnection {
|
|
|
1703
1703
|
sent: number;
|
|
1704
1704
|
finished: boolean;
|
|
1705
1705
|
process: StubProcess | null;
|
|
1706
|
-
readonly
|
|
1706
|
+
readonly stop: (outcome: ImpSessionOutcome) => void;
|
|
1707
1707
|
}
|
|
1708
1708
|
|
|
1709
1709
|
interface StubRelay {
|
|
@@ -1743,7 +1743,7 @@ function tryEmit(connection: StubConnection, emit: () => void): boolean {
|
|
|
1743
1743
|
|
|
1744
1744
|
return true;
|
|
1745
1745
|
} catch (error) {
|
|
1746
|
-
connection.
|
|
1746
|
+
connection.stop({
|
|
1747
1747
|
kind: 'local_error',
|
|
1748
1748
|
detail: error instanceof Error ? error.message : String(error),
|
|
1749
1749
|
});
|
|
@@ -1752,14 +1752,14 @@ function tryEmit(connection: StubConnection, emit: () => void): boolean {
|
|
|
1752
1752
|
}
|
|
1753
1753
|
}
|
|
1754
1754
|
|
|
1755
|
-
function findSessionName(imp: StubImp, proc: StubProcess): string {
|
|
1755
|
+
function findSessionName(imp: StubImp, proc: StubProcess): string | undefined {
|
|
1756
1756
|
for (const [name, held] of imp.sessions) {
|
|
1757
1757
|
if (held === proc) {
|
|
1758
1758
|
return name;
|
|
1759
1759
|
}
|
|
1760
1760
|
}
|
|
1761
1761
|
|
|
1762
|
-
return
|
|
1762
|
+
return undefined;
|
|
1763
1763
|
}
|
|
1764
1764
|
|
|
1765
1765
|
function toSignal(name: string): NodeJS.Signals {
|
|
@@ -8,7 +8,7 @@ interface StubSystemd {
|
|
|
8
8
|
// first on the PATH of every process that could reach a real one.
|
|
9
9
|
readonly binDir: string;
|
|
10
10
|
|
|
11
|
-
// The directory `ATC_PROC_ROOT` points at, where `
|
|
11
|
+
// The directory `ATC_PROC_ROOT` points at, where `writeUnitCgroup` writes.
|
|
12
12
|
readonly procRoot: string;
|
|
13
13
|
|
|
14
14
|
// The arguments of every `systemctl` call, one line per call.
|
|
@@ -22,9 +22,9 @@ interface StubSystemd {
|
|
|
22
22
|
|
|
23
23
|
// Places the pid in a user service of the given name, as the cgroup of a
|
|
24
24
|
// daemon run by a user manager reads.
|
|
25
|
-
readonly
|
|
25
|
+
readonly writeUnitCgroup: (pid: number, unit: string) => void;
|
|
26
26
|
|
|
27
|
-
readonly
|
|
27
|
+
readonly remove: () => void;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -36,7 +36,7 @@ interface StubSystemd {
|
|
|
36
36
|
* `--` in the background with only the `--setenv` variables, as a transient
|
|
37
37
|
* unit sees them, and appends its output to the `StandardOutput=append:`
|
|
38
38
|
* file. Every call is logged. The stand-ins' directory is removed once the
|
|
39
|
-
* current test finishes, so it must run inside a test;
|
|
39
|
+
* current test finishes, so it must run inside a test; `remove` removes it
|
|
40
40
|
* sooner, and a second removal does nothing.
|
|
41
41
|
*/
|
|
42
42
|
export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
|
|
@@ -118,7 +118,7 @@ exit 0
|
|
|
118
118
|
writeMainPID: (pid) => {
|
|
119
119
|
writeFileSync(join(root, 'mainpid'), String(pid));
|
|
120
120
|
},
|
|
121
|
-
|
|
121
|
+
writeUnitCgroup: (pid, unit) => {
|
|
122
122
|
mkdirSync(join(procRoot, String(pid)), { recursive: true });
|
|
123
123
|
|
|
124
124
|
writeFileSync(
|
|
@@ -126,6 +126,6 @@ exit 0
|
|
|
126
126
|
`0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
|
|
127
127
|
);
|
|
128
128
|
},
|
|
129
|
-
|
|
129
|
+
remove,
|
|
130
130
|
};
|
|
131
131
|
}
|
|
@@ -16,7 +16,7 @@ if (marker === undefined) {
|
|
|
16
16
|
stopRun('a bare `bun test` runs against your real home; run `bun run test` instead');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
requireTestHome(marker);
|
|
20
20
|
|
|
21
21
|
afterEach(() => {
|
|
22
22
|
removeEnvOverrides();
|
|
@@ -26,7 +26,7 @@ afterEach(() => {
|
|
|
26
26
|
// points under it and no enclosing session's variables remain; a stale or
|
|
27
27
|
// hand-set marker stops the run before any test imports atc. An inherited
|
|
28
28
|
// CLAUDE_CONFIG_DIR would move the Claude config folder out of the home.
|
|
29
|
-
function
|
|
29
|
+
function requireTestHome(root: string): void {
|
|
30
30
|
const expected: Readonly<Record<string, string>> = {
|
|
31
31
|
HOME: join(root, 'home'),
|
|
32
32
|
XDG_RUNTIME_DIR: join(root, 'runtime'),
|
|
@@ -42,9 +42,12 @@ function assertTestHome(root: string): void {
|
|
|
42
42
|
.filter(([name, path]) => normalize(process.env[name] ?? '') !== path)
|
|
43
43
|
.map(([name]) => name);
|
|
44
44
|
|
|
45
|
-
const leftover = [
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
const leftover = [
|
|
46
|
+
'ATC_SESSION_ID',
|
|
47
|
+
'ATC_SOCKET',
|
|
48
|
+
'ATC_SESSION_RECORD',
|
|
49
|
+
'CLAUDE_CONFIG_DIR',
|
|
50
|
+
].filter((name) => name in process.env);
|
|
48
51
|
|
|
49
52
|
if (mismatched.length > 0 || leftover.length > 0) {
|
|
50
53
|
stopRun(
|
|
@@ -9,14 +9,18 @@ import type { CompiledQuery } from 'kysely';
|
|
|
9
9
|
* before returning, so the query never runs and nothing is written.
|
|
10
10
|
*/
|
|
11
11
|
export function readQueryPlan(dbPath: string, query: CompiledQuery): string {
|
|
12
|
-
|
|
12
|
+
const db = new Database(dbPath, { readonly: true });
|
|
13
13
|
|
|
14
14
|
// oxlint-disable-next-line no-unsafe-type-assertion -- kysely's sqlite compiler only ever binds bun:sqlite-legal values
|
|
15
15
|
const bindings = query.parameters as SQLQueryBindings[];
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
try {
|
|
18
|
+
return db
|
|
19
|
+
.query<{ detail: string }, SQLQueryBindings[]>(`EXPLAIN QUERY PLAN ${query.sql}`)
|
|
20
|
+
.all(...bindings)
|
|
21
|
+
.map((row) => row.detail)
|
|
22
|
+
.join('\n');
|
|
23
|
+
} finally {
|
|
24
|
+
db.close();
|
|
25
|
+
}
|
|
22
26
|
}
|
|
@@ -7,16 +7,16 @@ let pending: (() => unknown)[] | null = null;
|
|
|
7
7
|
/**
|
|
8
8
|
* Registers the release of a resource a test just acquired, to run once the
|
|
9
9
|
* current test finishes, and returns that release for the resource's own
|
|
10
|
-
*
|
|
11
|
-
* hook, last registered first,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
10
|
+
* early release, such as a `stop` member. Every release a test registers
|
|
11
|
+
* runs from one hook, last registered first, so a directory made before a
|
|
12
|
+
* process that lives in it is removed only after that process stops. A
|
|
13
|
+
* release that throws stops none of the others: the hook rethrows once
|
|
14
|
+
* every release has run, as an `AggregateError` when more than one threw.
|
|
15
|
+
* The returned release runs its callback once: every later call, from the
|
|
16
|
+
* hook or from the test, returns what the first call returned. Bun runs the
|
|
17
|
+
* hook after every `afterEach`, so a release works from the paths and
|
|
18
|
+
* handles it captured. Throws outside a test, where nothing would run the
|
|
19
|
+
* hook.
|
|
20
20
|
*/
|
|
21
21
|
export function registerTestCleanup<T>(release: () => T): () => T {
|
|
22
22
|
let released: { readonly value: T } | null = null;
|
|
@@ -15,12 +15,13 @@ import { waitFor } from './wait-for';
|
|
|
15
15
|
* no extra arguments. A server started with this home as its `HOME` and
|
|
16
16
|
* `XDG_RUNTIME_DIR` boots its daemon here. Each stand-in appends its pid,
|
|
17
17
|
* which is its process group as a session leader, to `stub-pids` in the
|
|
18
|
-
* home.
|
|
18
|
+
* home. `teardown` stops the daemon, when one wrote its pid file here, and
|
|
19
19
|
* waits for it to exit; then it kills the daemon's process group and every
|
|
20
20
|
* group `stub-pids` records, such as a reporter a stopped session left
|
|
21
21
|
* behind, waits until each group is empty, and removes the home. That
|
|
22
|
-
*
|
|
23
|
-
* test;
|
|
22
|
+
* teardown runs once the current test finishes, so it must run inside a
|
|
23
|
+
* test; calling `teardown` sooner runs it then, and a second teardown does
|
|
24
|
+
* nothing.
|
|
24
25
|
*/
|
|
25
26
|
export function setupMCPHome() {
|
|
26
27
|
const tmp = setupTempDir('atc-mcp-');
|
|
@@ -29,9 +30,9 @@ export function setupMCPHome() {
|
|
|
29
30
|
// stand-ins stop before the home that records them is removed.
|
|
30
31
|
const stack = new AsyncDisposableStack();
|
|
31
32
|
|
|
32
|
-
const
|
|
33
|
+
const teardown = registerTestCleanup(() => stack.disposeAsync());
|
|
33
34
|
|
|
34
|
-
stack.
|
|
35
|
+
stack.defer(tmp.teardown);
|
|
35
36
|
|
|
36
37
|
const home = tmp.dir;
|
|
37
38
|
|
|
@@ -60,7 +61,7 @@ export function setupMCPHome() {
|
|
|
60
61
|
home,
|
|
61
62
|
claudeBin,
|
|
62
63
|
grokBin,
|
|
63
|
-
|
|
64
|
+
teardown,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
|
@@ -21,13 +21,13 @@ interface MCPHTTPSetupOptions {
|
|
|
21
21
|
* collected. The authorization server's database sits where atc keeps it
|
|
22
22
|
* under `home`, so a CLI run with that home opens the same file. `store` is
|
|
23
23
|
* the authorization server's database opened a second time the way
|
|
24
|
-
* `atc clients` and `atc grants` open it, and `
|
|
24
|
+
* `atc clients` and `atc grants` open it, and `createClient` adds a client
|
|
25
25
|
* through it and returns the client id. `restartDaemon` stops the daemon and
|
|
26
26
|
* starts a fresh one on the same socket and database, the way an operator
|
|
27
27
|
* restarts it, with the principals it is given; `countDaemonClients` reads
|
|
28
28
|
* how many connections the current daemon holds open. Everything it starts
|
|
29
29
|
* stops, and the directory is removed, once the current test finishes, so it
|
|
30
|
-
* must run inside a test;
|
|
30
|
+
* must run inside a test; `teardown` does so sooner, and a second teardown
|
|
31
31
|
* does nothing.
|
|
32
32
|
*/
|
|
33
33
|
export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
@@ -37,9 +37,9 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
37
37
|
// before the one it depends on, and the directory goes last.
|
|
38
38
|
const stack = new AsyncDisposableStack();
|
|
39
39
|
|
|
40
|
-
const
|
|
40
|
+
const teardown = registerTestCleanup(() => stack.disposeAsync());
|
|
41
41
|
|
|
42
|
-
stack.
|
|
42
|
+
stack.defer(tmp.teardown);
|
|
43
43
|
|
|
44
44
|
const socketPath = join(tmp.dir, 'daemon.sock');
|
|
45
45
|
const stateDir = join(tmp.dir, '.local', 'state', 'atc');
|
|
@@ -102,7 +102,7 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
102
102
|
requests,
|
|
103
103
|
caller,
|
|
104
104
|
store,
|
|
105
|
-
async
|
|
105
|
+
async createClient(name: string, redirectURIs: readonly string[]): Promise<string> {
|
|
106
106
|
const created = await store.auth.api.createFixedClient({
|
|
107
107
|
body: { name, redirectURIs: [...redirectURIs] },
|
|
108
108
|
});
|
|
@@ -117,6 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
|
|
|
117
117
|
|
|
118
118
|
daemon = await startTestDaemon(principals);
|
|
119
119
|
},
|
|
120
|
-
|
|
120
|
+
teardown,
|
|
121
121
|
};
|
|
122
122
|
}
|
|
@@ -5,30 +5,24 @@ import { registerTestCleanup } from './register-test-cleanup';
|
|
|
5
5
|
|
|
6
6
|
interface TempDir {
|
|
7
7
|
readonly dir: string;
|
|
8
|
-
readonly
|
|
9
|
-
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
8
|
+
readonly teardown: () => void;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* Creates a directory under the system temp root, named by the prefix, and
|
|
14
13
|
* registers its removal to run once the current test finishes, so it must
|
|
15
|
-
* run inside a test.
|
|
14
|
+
* run inside a test. `teardown` removes the tree sooner; whichever comes
|
|
16
15
|
* second does nothing.
|
|
17
16
|
*/
|
|
18
17
|
export function setupTempDir(prefix: string): TempDir {
|
|
19
18
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
20
19
|
|
|
21
|
-
const
|
|
20
|
+
const teardown = registerTestCleanup(() => {
|
|
22
21
|
rmSync(dir, { recursive: true, force: true });
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
return {
|
|
26
25
|
dir,
|
|
27
|
-
|
|
28
|
-
[Symbol.asyncDispose]: () => {
|
|
29
|
-
remove();
|
|
30
|
-
|
|
31
|
-
return Promise.resolve();
|
|
32
|
-
},
|
|
26
|
+
teardown,
|
|
33
27
|
};
|
|
34
28
|
}
|
|
@@ -25,7 +25,6 @@ export interface CutProxy {
|
|
|
25
25
|
// How many answers the proxy has swallowed in hold mode.
|
|
26
26
|
readonly countHeld: () => number;
|
|
27
27
|
readonly stop: () => void;
|
|
28
|
-
readonly [Symbol.dispose]: () => void;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
// One proxied connection: the target side once it is connected, what the
|
|
@@ -49,8 +48,8 @@ interface ProxyLink {
|
|
|
49
48
|
* the method it loses the target's next answer after the request reached
|
|
50
49
|
* the target: the request runs, and the client never sees its response.
|
|
51
50
|
* It stops, ending every link, once the current test finishes, so it must
|
|
52
|
-
* run inside a test; `stop`
|
|
53
|
-
*
|
|
51
|
+
* run inside a test; `stop` stops it sooner, and a second stop does
|
|
52
|
+
* nothing.
|
|
54
53
|
*/
|
|
55
54
|
export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
56
55
|
let cutsLeft = options.cuts;
|
|
@@ -139,7 +138,6 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
|
|
|
139
138
|
countRequests: () => requests,
|
|
140
139
|
countHeld: () => held,
|
|
141
140
|
stop,
|
|
142
|
-
[Symbol.dispose]: stop,
|
|
143
141
|
};
|
|
144
142
|
}
|
|
145
143
|
|
|
@@ -39,12 +39,12 @@ interface DaemonBoot {
|
|
|
39
39
|
* sending no handshake, and fails at once with the daemon's stderr when the
|
|
40
40
|
* daemon exits before it listens. `restart` stops the daemon with the given
|
|
41
41
|
* signal, waits for it to exit, and starts another on the same home with the
|
|
42
|
-
* same config. `readStderr` reads what the current boot printed.
|
|
42
|
+
* same config. `readStderr` reads what the current boot printed. `stop`
|
|
43
43
|
* closes every client it opened, kills the daemon and waits for it to exit,
|
|
44
44
|
* then kills the daemon the home's state directory records, which a restart
|
|
45
|
-
* the test asked atc for may have started. That
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* the test asked atc for may have started. That stop runs once the current
|
|
46
|
+
* test finishes, so it must run inside a test; calling `stop` sooner runs it
|
|
47
|
+
* then, and a second stop does nothing.
|
|
48
48
|
*/
|
|
49
49
|
export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
50
50
|
const socketPath = join(config.home, 'atc-daemon.sock');
|
|
@@ -53,7 +53,7 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
53
53
|
const clients = new Set<DaemonClient>();
|
|
54
54
|
|
|
55
55
|
let boots = 0;
|
|
56
|
-
let
|
|
56
|
+
let stopped = false;
|
|
57
57
|
|
|
58
58
|
const boot = (): DaemonBoot => {
|
|
59
59
|
boots++;
|
|
@@ -79,8 +79,8 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
79
79
|
|
|
80
80
|
let current = boot();
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
|
|
82
|
+
const stop = registerTestCleanup(async () => {
|
|
83
|
+
stopped = true;
|
|
84
84
|
|
|
85
85
|
for (const client of clients) {
|
|
86
86
|
client.stop();
|
|
@@ -107,12 +107,12 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
107
107
|
}
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
-
// A client that opens after the wait for it ended, or after
|
|
110
|
+
// A client that opens after the wait for it ended, or after the stop,
|
|
111
111
|
// closes at once, so nothing outlives the daemon it reached.
|
|
112
112
|
const openTrackedClient = async (isAbandoned: () => boolean) => {
|
|
113
113
|
const client = await DaemonClient.open(socketPath);
|
|
114
114
|
|
|
115
|
-
if (
|
|
115
|
+
if (stopped || isAbandoned()) {
|
|
116
116
|
client.stop();
|
|
117
117
|
throw new Error('the wait for the daemon ended before this client opened');
|
|
118
118
|
}
|
|
@@ -132,7 +132,7 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
132
132
|
const openAfterExit = async () => {
|
|
133
133
|
await watched.exited;
|
|
134
134
|
|
|
135
|
-
if (settled ||
|
|
135
|
+
if (settled || stopped) {
|
|
136
136
|
return null;
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -146,10 +146,10 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
146
146
|
};
|
|
147
147
|
|
|
148
148
|
// Polls the socket while the daemon runs. Once the other wait settles or
|
|
149
|
-
// the daemon
|
|
149
|
+
// the daemon stops, each attempt resolves empty, which ends the
|
|
150
150
|
// polling.
|
|
151
151
|
const openWhenListening = () =>
|
|
152
|
-
waitFor(() => (settled ||
|
|
152
|
+
waitFor(() => (settled || stopped ? null : openTrackedClient(isSettled)), {
|
|
153
153
|
timeoutMs: 15_000,
|
|
154
154
|
intervalMs: 50,
|
|
155
155
|
});
|
|
@@ -188,6 +188,6 @@ export function startDaemonProcess(config: Readonly<DaemonProcessConfig>) {
|
|
|
188
188
|
|
|
189
189
|
current = boot();
|
|
190
190
|
},
|
|
191
|
-
|
|
191
|
+
stop,
|
|
192
192
|
};
|
|
193
193
|
}
|
|
@@ -7,7 +7,6 @@ interface GitHTTPServer {
|
|
|
7
7
|
// Every Authorization header a request carried, in arrival order.
|
|
8
8
|
readonly authorizations: string[];
|
|
9
9
|
readonly stop: () => Promise<void>;
|
|
10
|
-
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
interface GitHTTPServerOptions {
|
|
@@ -30,8 +29,8 @@ interface GitHTTPServerOptions {
|
|
|
30
29
|
* clone through it slow. An `onRequest` callback runs, and is awaited, while
|
|
31
30
|
* each authenticated request is held, so it sees the client that sent the
|
|
32
31
|
* request still running. It stops once the current test finishes, so it
|
|
33
|
-
* must run inside a test; `stop`
|
|
34
|
-
*
|
|
32
|
+
* must run inside a test; `stop` stops it sooner, and a second stop does
|
|
33
|
+
* nothing.
|
|
35
34
|
*/
|
|
36
35
|
export function startGitHTTPServer(
|
|
37
36
|
root: string,
|
|
@@ -97,7 +96,6 @@ export function startGitHTTPServer(
|
|
|
97
96
|
url: `http://127.0.0.1:${server.port}/`,
|
|
98
97
|
authorizations,
|
|
99
98
|
stop,
|
|
100
|
-
[Symbol.asyncDispose]: stop,
|
|
101
99
|
};
|
|
102
100
|
}
|
|
103
101
|
|