@zgeoff/atc 2.15.1 → 2.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +63 -76
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +241 -89
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import type { MessageOwner } from '../store/message-owner';
|
|
4
|
+
import type { MessageRecord } from '../store/message-record';
|
|
5
|
+
import type { TapClient } from './daemon-context';
|
|
6
|
+
import type { TapGeneration, TapRegistry } from './tap-registry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a drain reads from: the taps, the owner a linked tap reads messages
|
|
10
|
+
* under (the session's atc id and agent session id, or null for a session
|
|
11
|
+
* the daemon does not hold), and the store's pending messages for an owner.
|
|
12
|
+
*/
|
|
13
|
+
export interface InboxSource {
|
|
14
|
+
readonly taps: TapRegistry<TapClient>;
|
|
15
|
+
readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
|
|
16
|
+
readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hands the session's tap the oldest pending message it has not taken. The
|
|
21
|
+
* backlog is read before anything is sent, so a tap's ok response is always
|
|
22
|
+
* queued ahead of its first message, and one message goes per call: the
|
|
23
|
+
* tap's ack calls it again, so the backlog never outgrows the connection's
|
|
24
|
+
* outbound queue. The drain is bound to the tap it found before the read: a
|
|
25
|
+
* tap another attach made while the read waited gets nothing from it.
|
|
26
|
+
*/
|
|
27
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
|
|
28
|
+
export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
|
|
29
|
+
const linkedOwner = source.findLinkedOwner(sessionID);
|
|
30
|
+
const tap = source.taps.findTap(sessionID);
|
|
31
|
+
|
|
32
|
+
if (linkedOwner === null || tap === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A tap a principal holds takes only the messages sent to this atc id:
|
|
37
|
+
// another session that shares the agent session id may be out of its
|
|
38
|
+
// reach.
|
|
39
|
+
const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const pending = await source.collectPendingMessages(owner);
|
|
43
|
+
|
|
44
|
+
for (const record of pending) {
|
|
45
|
+
if (sendInboxMessage(sessionID, record, tap, source.taps)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Hands one pending message to the tap the drain was made for, once, and
|
|
53
|
+
// reports whether it did. An unlinked tap takes only a message sent to the
|
|
54
|
+
// session's own atc id. The event goes to the tap connection alone: it never
|
|
55
|
+
// reaches other clients, the events socket, or hooks.
|
|
56
|
+
function sendInboxMessage(
|
|
57
|
+
sessionID: SessionID,
|
|
58
|
+
record: MessageRecord,
|
|
59
|
+
tap: TapGeneration,
|
|
60
|
+
|
|
61
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
|
|
62
|
+
taps: TapRegistry<TapClient>,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!tap.linked && record.atcID !== sessionID) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const client = taps.claimDelivery(sessionID, record.id, tap.generation);
|
|
69
|
+
|
|
70
|
+
if (client === null) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
client.sendEvent({
|
|
75
|
+
v: PROTOCOL_V,
|
|
76
|
+
ev: 'InboxMessage',
|
|
77
|
+
s: sessionID,
|
|
78
|
+
message: record.id,
|
|
79
|
+
from: record.from,
|
|
80
|
+
text: record.text,
|
|
81
|
+
sentAt: record.sentAt,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
3
|
+
|
|
4
|
+
// A session as the tree check reads it: its id, its parent if it has one,
|
|
5
|
+
// and the target it is bound to.
|
|
6
|
+
interface TreeMember extends TargetGrant {
|
|
7
|
+
readonly id: SessionID;
|
|
8
|
+
readonly parent: SessionID | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether the access reaches every session in the tree the given session
|
|
13
|
+
* belongs to: its top-level session and each sub-session of that one. A
|
|
14
|
+
* session the list does not hold, or one whose parent it does not hold,
|
|
15
|
+
* is out of reach.
|
|
16
|
+
*/
|
|
17
|
+
export function isTreeInReach(
|
|
18
|
+
sessions: readonly TreeMember[],
|
|
19
|
+
id: SessionID,
|
|
20
|
+
access: TargetAccess,
|
|
21
|
+
): boolean {
|
|
22
|
+
const session = sessions.find((x) => x.id === id);
|
|
23
|
+
|
|
24
|
+
if (session === undefined) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const root = session.parent ?? session.id;
|
|
29
|
+
const tree = sessions.filter((x) => x.id === root || x.parent === root);
|
|
30
|
+
|
|
31
|
+
return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
|
|
32
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -479,8 +479,15 @@ export class SessionManager {
|
|
|
479
479
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
480
480
|
// same agent session id. On a remote host the host wakes first, and a
|
|
481
481
|
// harness still running inside it is attached rather than started again;
|
|
482
|
-
// every other session left asleep on that host comes back with it.
|
|
483
|
-
|
|
482
|
+
// every other session left asleep on that host comes back with it. The
|
|
483
|
+
// caller's check runs again after each await, and a failed check leaves
|
|
484
|
+
// the session as it was.
|
|
485
|
+
async adoptTerminal(
|
|
486
|
+
id: SessionID,
|
|
487
|
+
cols: number,
|
|
488
|
+
rows: number,
|
|
489
|
+
canProceed: () => boolean = () => true,
|
|
490
|
+
): Promise<Session | null> {
|
|
484
491
|
const s = this.sessions.find((x) => x.id === id);
|
|
485
492
|
|
|
486
493
|
if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
|
|
@@ -489,7 +496,7 @@ export class SessionManager {
|
|
|
489
496
|
|
|
490
497
|
const settled = await this.isRollbackSettled(id);
|
|
491
498
|
|
|
492
|
-
if (!settled) {
|
|
499
|
+
if (!settled || !canProceed()) {
|
|
493
500
|
return null;
|
|
494
501
|
}
|
|
495
502
|
|
|
@@ -516,9 +523,11 @@ export class SessionManager {
|
|
|
516
523
|
this.adopting.delete(id);
|
|
517
524
|
}
|
|
518
525
|
|
|
519
|
-
// A kill, a second adopt,
|
|
520
|
-
// the host
|
|
526
|
+
// A kill, a second adopt, a failed spawn's rollback, or a change that
|
|
527
|
+
// takes the session out of the caller's reach can land while the host
|
|
528
|
+
// wakes.
|
|
521
529
|
if (
|
|
530
|
+
!canProceed() ||
|
|
522
531
|
s.pty !== null ||
|
|
523
532
|
!this.sessions.includes(s) ||
|
|
524
533
|
this.rollingBack.has(id) ||
|
|
@@ -729,6 +738,7 @@ export class SessionManager {
|
|
|
729
738
|
id: SessionID = mintSessionID(),
|
|
730
739
|
target = 'local',
|
|
731
740
|
materialized: MaterializedSpawn | null = null,
|
|
741
|
+
requireInReach: () => void = () => {},
|
|
732
742
|
): Promise<Session> {
|
|
733
743
|
const adapter = this.findAdapter(agent);
|
|
734
744
|
|
|
@@ -751,6 +761,10 @@ export class SessionManager {
|
|
|
751
761
|
...overrides,
|
|
752
762
|
});
|
|
753
763
|
|
|
764
|
+
// The caller's check runs again once the host is ready, before the
|
|
765
|
+
// harness starts.
|
|
766
|
+
requireInReach();
|
|
767
|
+
|
|
754
768
|
const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
|
|
755
769
|
|
|
756
770
|
const pty = provider.spawnHarness({
|
|
@@ -1248,7 +1262,8 @@ export class SessionManager {
|
|
|
1248
1262
|
// every harness on it kept inside, and fails whole when the host stays
|
|
1249
1263
|
// awake. A dead session on a target that can destroy its host is not
|
|
1250
1264
|
// forgotten by a kill: forgetting it destroys the host, which takes a
|
|
1251
|
-
// confirmed forget.
|
|
1265
|
+
// confirmed forget. The set is taken before the first await, so a
|
|
1266
|
+
// sub-session spawned while the kill waits on a host is not part of it.
|
|
1252
1267
|
async kill(id: SessionID): Promise<void> {
|
|
1253
1268
|
const s = this.sessions.find((x) => x.id === id);
|
|
1254
1269
|
|
|
@@ -1256,11 +1271,15 @@ export class SessionManager {
|
|
|
1256
1271
|
return;
|
|
1257
1272
|
}
|
|
1258
1273
|
|
|
1274
|
+
const children = this.collectChildren(id);
|
|
1275
|
+
|
|
1259
1276
|
if (s.pty) {
|
|
1260
1277
|
await this.stopHarness(s);
|
|
1261
1278
|
|
|
1262
|
-
for (const child of
|
|
1263
|
-
|
|
1279
|
+
for (const child of children) {
|
|
1280
|
+
if (this.sessions.includes(child) && child.parent === id) {
|
|
1281
|
+
await this.tryStopHarness(child);
|
|
1282
|
+
}
|
|
1264
1283
|
}
|
|
1265
1284
|
} else {
|
|
1266
1285
|
if (this.findProvider(s)?.capabilities.destroy === true) {
|
|
@@ -1271,8 +1290,7 @@ export class SessionManager {
|
|
|
1271
1290
|
);
|
|
1272
1291
|
}
|
|
1273
1292
|
|
|
1274
|
-
this.
|
|
1275
|
-
this.remove(s);
|
|
1293
|
+
this.removeWithChildren(s, children);
|
|
1276
1294
|
}
|
|
1277
1295
|
|
|
1278
1296
|
await this.writeFleet();
|
|
@@ -1288,7 +1306,9 @@ export class SessionManager {
|
|
|
1288
1306
|
* kept asleep in that host is refused until the host wakes. Its dead
|
|
1289
1307
|
* sub-sessions on other hosts go with it, unless their own target can
|
|
1290
1308
|
* destroy their host, and its live ones become top-level. A failed destroy
|
|
1291
|
-
* throws before anything is forgotten.
|
|
1309
|
+
* throws before anything is forgotten. The sub-sessions it may forget are
|
|
1310
|
+
* taken before the first await; one spawned while the forget waits on a
|
|
1311
|
+
* host becomes top-level.
|
|
1292
1312
|
*/
|
|
1293
1313
|
async forget(id: SessionID): Promise<boolean> {
|
|
1294
1314
|
const s = this.sessions.find((x) => x.id === id);
|
|
@@ -1297,6 +1317,7 @@ export class SessionManager {
|
|
|
1297
1317
|
return false;
|
|
1298
1318
|
}
|
|
1299
1319
|
|
|
1320
|
+
const children = this.collectChildren(id);
|
|
1300
1321
|
const provider = this.findProvider(s);
|
|
1301
1322
|
const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
|
|
1302
1323
|
|
|
@@ -1330,8 +1351,7 @@ export class SessionManager {
|
|
|
1330
1351
|
this.killTerminal(s);
|
|
1331
1352
|
}
|
|
1332
1353
|
|
|
1333
|
-
this.
|
|
1334
|
-
this.remove(s);
|
|
1354
|
+
this.removeWithChildren(s, children);
|
|
1335
1355
|
|
|
1336
1356
|
await this.writeFleet();
|
|
1337
1357
|
|
|
@@ -1340,22 +1360,42 @@ export class SessionManager {
|
|
|
1340
1360
|
return destroys;
|
|
1341
1361
|
}
|
|
1342
1362
|
|
|
1343
|
-
//
|
|
1344
|
-
// target can destroy its host: forgetting that one destroys the host,
|
|
1345
|
-
// takes its own confirmed forget.
|
|
1346
|
-
// top-level
|
|
1347
|
-
|
|
1348
|
-
|
|
1363
|
+
// Removes a forgotten session and its dead sub-sessions, except one whose
|
|
1364
|
+
// own target can destroy its host: forgetting that one destroys the host,
|
|
1365
|
+
// which takes its own confirmed forget. Only a sub-session among the given
|
|
1366
|
+
// ones may go. Every sub-session that stays becomes top-level before the
|
|
1367
|
+
// session's removal is announced, and is announced after it, so no
|
|
1368
|
+
// announcement ever finds a sub-session whose parent is gone or a parent
|
|
1369
|
+
// standing without the sub-sessions that leave with it.
|
|
1370
|
+
private removeWithChildren(s: Session, forgettable: readonly Session[]): void {
|
|
1371
|
+
const kept: Session[] = [];
|
|
1372
|
+
const dropped: Session[] = [];
|
|
1373
|
+
|
|
1374
|
+
for (const child of this.collectChildren(s.id)) {
|
|
1349
1375
|
const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
|
|
1350
1376
|
|
|
1351
|
-
if (
|
|
1377
|
+
if (
|
|
1378
|
+
live ||
|
|
1379
|
+
!forgettable.includes(child) ||
|
|
1380
|
+
this.findProvider(child)?.capabilities.destroy === true
|
|
1381
|
+
) {
|
|
1352
1382
|
child.parent = null;
|
|
1353
1383
|
|
|
1354
|
-
|
|
1384
|
+
kept.push(child);
|
|
1355
1385
|
} else {
|
|
1356
|
-
|
|
1386
|
+
dropped.push(child);
|
|
1357
1387
|
}
|
|
1358
1388
|
}
|
|
1389
|
+
|
|
1390
|
+
this.remove(s);
|
|
1391
|
+
|
|
1392
|
+
for (const child of kept) {
|
|
1393
|
+
this.onEvent('state', child);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
for (const child of dropped) {
|
|
1397
|
+
this.remove(child);
|
|
1398
|
+
}
|
|
1359
1399
|
}
|
|
1360
1400
|
|
|
1361
1401
|
/**
|
|
@@ -4,6 +4,23 @@ import type { SessionID } from '../shared/session-id';
|
|
|
4
4
|
interface TapEntry<TClient> {
|
|
5
5
|
readonly client: TClient;
|
|
6
6
|
readonly claimed: Set<MessageID>;
|
|
7
|
+
|
|
8
|
+
// Whether the tap also takes messages held under the session's agent
|
|
9
|
+
// session id, as opposed to its atc id alone.
|
|
10
|
+
readonly linked: boolean;
|
|
11
|
+
|
|
12
|
+
// Which attach made this tap; every attach makes a new one.
|
|
13
|
+
readonly generation: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A session's tap as a drain finds it before it waits: the attach that made
|
|
18
|
+
* it and whether it is linked. A delivery made for it lands only while the
|
|
19
|
+
* same attach holds the tap.
|
|
20
|
+
*/
|
|
21
|
+
export interface TapGeneration {
|
|
22
|
+
readonly generation: number;
|
|
23
|
+
readonly linked: boolean;
|
|
7
24
|
}
|
|
8
25
|
|
|
9
26
|
/**
|
|
@@ -13,14 +30,24 @@ interface TapEntry<TClient> {
|
|
|
13
30
|
export class TapRegistry<TClient> {
|
|
14
31
|
private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
|
|
15
32
|
|
|
33
|
+
private nextGeneration = 0;
|
|
34
|
+
|
|
16
35
|
/**
|
|
17
36
|
* Makes the client the session's tap and returns the client it displaced,
|
|
18
|
-
* or null when there was none or it is the same connection.
|
|
37
|
+
* or null when there was none or it is the same connection. A linked tap
|
|
38
|
+
* also takes the messages held under the session's agent session id.
|
|
19
39
|
*/
|
|
20
|
-
attach(sessionID: SessionID, client: TClient): TClient | null {
|
|
40
|
+
attach(sessionID: SessionID, client: TClient, linked = true): TClient | null {
|
|
21
41
|
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
22
42
|
|
|
23
|
-
this.
|
|
43
|
+
this.nextGeneration += 1;
|
|
44
|
+
|
|
45
|
+
this.bySession.set(sessionID, {
|
|
46
|
+
client,
|
|
47
|
+
claimed: new Set(),
|
|
48
|
+
linked,
|
|
49
|
+
generation: this.nextGeneration,
|
|
50
|
+
});
|
|
24
51
|
|
|
25
52
|
return previous === client ? null : previous;
|
|
26
53
|
}
|
|
@@ -45,6 +72,12 @@ export class TapRegistry<TClient> {
|
|
|
45
72
|
return previous;
|
|
46
73
|
}
|
|
47
74
|
|
|
75
|
+
findTap(sessionID: SessionID): TapGeneration | null {
|
|
76
|
+
const entry = this.bySession.get(sessionID);
|
|
77
|
+
|
|
78
|
+
return entry === undefined ? null : { generation: entry.generation, linked: entry.linked };
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
hasTap(sessionID: SessionID): boolean {
|
|
49
82
|
return this.bySession.has(sessionID);
|
|
50
83
|
}
|
|
@@ -53,10 +86,15 @@ export class TapRegistry<TClient> {
|
|
|
53
86
|
return this.bySession.get(sessionID)?.client === client;
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Claims the message for the session's tap and returns its client, or
|
|
91
|
+
* null when the tap already took it or another attach replaced the tap
|
|
92
|
+
* the delivery was made for.
|
|
93
|
+
*/
|
|
94
|
+
claimDelivery(sessionID: SessionID, messageID: MessageID, generation: number): TClient | null {
|
|
57
95
|
const entry = this.bySession.get(sessionID);
|
|
58
96
|
|
|
59
|
-
if (entry === undefined || entry.claimed.has(messageID)) {
|
|
97
|
+
if (entry === undefined || entry.generation !== generation || entry.claimed.has(messageID)) {
|
|
60
98
|
return null;
|
|
61
99
|
}
|
|
62
100
|
|
|
@@ -56,6 +56,8 @@ interface EventsTable {
|
|
|
56
56
|
|
|
57
57
|
interface SpawnHistoryTable {
|
|
58
58
|
cwd: string;
|
|
59
|
+
target: string;
|
|
60
|
+
target_identity: string;
|
|
59
61
|
last_spawn: number;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -152,8 +154,9 @@ export interface StateStoreSchema {
|
|
|
152
154
|
// agent_session_id under its Claude-era name plus name and cwd, and each
|
|
153
155
|
// later step adds one column the daemon grew to depend on, until the
|
|
154
156
|
// rebuild that keys the table by the atc session id. events later gains
|
|
155
|
-
// columns of its own, as does messages,
|
|
156
|
-
//
|
|
157
|
+
// columns of its own, as does messages, spawn_history is rebuilt to key each
|
|
158
|
+
// directory by the target it was spawned on, and prefs has carried one shape
|
|
159
|
+
// since it was added.
|
|
157
160
|
const MIGRATIONS: Record<string, Migration> = {
|
|
158
161
|
'001_create_initial_schema': {
|
|
159
162
|
async up(db: Kysely<StateStoreSchema>) {
|
|
@@ -446,6 +449,29 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
446
449
|
await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
|
|
447
450
|
},
|
|
448
451
|
},
|
|
452
|
+
'024_rebuild_spawn_history_keyed_by_target': {
|
|
453
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
454
|
+
// The ledger records this step only after it returns, so a crash in
|
|
455
|
+
// between leaves a rebuilt table behind a ledger that still lacks it.
|
|
456
|
+
const columns = await sql<ColumnInfoRow>`PRAGMA table_info(spawn_history)`.execute(db);
|
|
457
|
+
|
|
458
|
+
if (columns.rows.some((column) => column.name === 'target')) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
await sql`BEGIN IMMEDIATE`.execute(db);
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
await updateSpawnHistoryKeyToTarget(db);
|
|
466
|
+
|
|
467
|
+
await sql`COMMIT`.execute(db);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
await sql`ROLLBACK`.execute(db);
|
|
470
|
+
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
},
|
|
449
475
|
};
|
|
450
476
|
|
|
451
477
|
const PROVIDER: MigrationProvider = {
|
|
@@ -457,8 +483,9 @@ const PROVIDER: MigrationProvider = {
|
|
|
457
483
|
* `Migrator`, one additive step at a time. A database from before the
|
|
458
484
|
* ladder existed is recognized at whichever shape it stopped at by a
|
|
459
485
|
* baselining pass that records the steps its columns already satisfy, so
|
|
460
|
-
* only what is genuinely missing runs.
|
|
461
|
-
*
|
|
486
|
+
* only what is genuinely missing runs. Two steps rebuild a table under a
|
|
487
|
+
* new key, the fleet and the spawn history, and carry every row across; no
|
|
488
|
+
* step drops a row.
|
|
462
489
|
*/
|
|
463
490
|
export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
|
|
464
491
|
// A baselined step can land out of order relative to a step that turned
|
|
@@ -652,6 +679,30 @@ interface ColumnInfoRow {
|
|
|
652
679
|
type: string;
|
|
653
680
|
}
|
|
654
681
|
|
|
682
|
+
// The identity of the local target with no options. Every spawn from before
|
|
683
|
+
// spawns recorded their target ran there.
|
|
684
|
+
const LEGACY_SPAWN_TARGET_IDENTITY = 'local-pty:44136fa355b3678a';
|
|
685
|
+
|
|
686
|
+
async function updateSpawnHistoryKeyToTarget(db: Kysely<StateStoreSchema>): Promise<void> {
|
|
687
|
+
await sql`
|
|
688
|
+
CREATE TABLE spawn_history_rebuilt (
|
|
689
|
+
cwd TEXT NOT NULL,
|
|
690
|
+
target TEXT NOT NULL,
|
|
691
|
+
target_identity TEXT NOT NULL,
|
|
692
|
+
last_spawn INTEGER NOT NULL,
|
|
693
|
+
PRIMARY KEY (cwd, target, target_identity)
|
|
694
|
+
)
|
|
695
|
+
`.execute(db);
|
|
696
|
+
|
|
697
|
+
await sql`
|
|
698
|
+
INSERT INTO spawn_history_rebuilt (cwd, target, target_identity, last_spawn)
|
|
699
|
+
SELECT cwd, 'local', ${LEGACY_SPAWN_TARGET_IDENTITY}, last_spawn FROM spawn_history
|
|
700
|
+
`.execute(db);
|
|
701
|
+
|
|
702
|
+
await sql`DROP TABLE spawn_history`.execute(db);
|
|
703
|
+
await sql`ALTER TABLE spawn_history_rebuilt RENAME TO spawn_history`.execute(db);
|
|
704
|
+
}
|
|
705
|
+
|
|
655
706
|
async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
|
|
656
707
|
const result = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
|
|
657
708
|
|
package/src/store/state-store.ts
CHANGED
|
@@ -64,6 +64,27 @@ export interface StoredEvent {
|
|
|
64
64
|
readonly label?: string;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The target a spawn ran on: its name and the identity the spawned session
|
|
68
|
+
// was bound to there.
|
|
69
|
+
interface SpawnTarget {
|
|
70
|
+
readonly target: string;
|
|
71
|
+
readonly targetIdentity: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A directory a spawn ran in and the target it ran on.
|
|
75
|
+
export interface SpawnDir {
|
|
76
|
+
readonly cwd: string;
|
|
77
|
+
readonly grant: SpawnTarget;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Another message one turn answered, and the atc id it was sent to.
|
|
82
|
+
*/
|
|
83
|
+
export interface TurnSibling {
|
|
84
|
+
readonly id: MessageID;
|
|
85
|
+
readonly atcID: SessionID;
|
|
86
|
+
}
|
|
87
|
+
|
|
67
88
|
/**
|
|
68
89
|
* One report as the trail holds it. A report recorded before the trail kept
|
|
69
90
|
* whole texts has only its preview, so its text is that preview and
|
|
@@ -513,24 +534,38 @@ export class StateStore {
|
|
|
513
534
|
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
514
535
|
}
|
|
515
536
|
|
|
516
|
-
async recordSpawnDir(cwd: string): Promise<void> {
|
|
537
|
+
async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
|
|
517
538
|
await this.db
|
|
518
539
|
.insertInto('spawn_history')
|
|
519
|
-
.values({
|
|
540
|
+
.values({
|
|
541
|
+
cwd,
|
|
542
|
+
target: grant.target,
|
|
543
|
+
target_identity: grant.targetIdentity,
|
|
544
|
+
last_spawn: Date.now(),
|
|
545
|
+
})
|
|
520
546
|
.onConflict((oc) =>
|
|
521
|
-
oc
|
|
547
|
+
oc
|
|
548
|
+
.columns(['cwd', 'target', 'target_identity'])
|
|
549
|
+
.doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
|
|
522
550
|
)
|
|
523
551
|
.execute();
|
|
524
552
|
}
|
|
525
553
|
|
|
526
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Each directory a spawn ran in, with the target it ran on, most recent
|
|
556
|
+
* first. A directory spawned on several targets has one entry for each.
|
|
557
|
+
*/
|
|
558
|
+
async collectSpawnDirs(): Promise<SpawnDir[]> {
|
|
527
559
|
const rows = await this.db
|
|
528
560
|
.selectFrom('spawn_history')
|
|
529
|
-
.select('cwd')
|
|
561
|
+
.select(['cwd', 'target', 'target_identity'])
|
|
530
562
|
.orderBy('last_spawn', 'desc')
|
|
531
563
|
.execute();
|
|
532
564
|
|
|
533
|
-
return rows.map((row) =>
|
|
565
|
+
return rows.map((row) => ({
|
|
566
|
+
cwd: row.cwd,
|
|
567
|
+
grant: { target: row.target, targetIdentity: row.target_identity },
|
|
568
|
+
}));
|
|
534
569
|
}
|
|
535
570
|
|
|
536
571
|
async loadLastUsedAgent(): Promise<AgentID> {
|
|
@@ -607,7 +642,7 @@ export class StateStore {
|
|
|
607
642
|
|
|
608
643
|
// The other messages of the same session the given message's turn
|
|
609
644
|
// answered, oldest first; none when the message has no turn.
|
|
610
|
-
async collectTurnSiblings(record: MessageRecord): Promise<
|
|
645
|
+
async collectTurnSiblings(record: MessageRecord): Promise<TurnSibling[]> {
|
|
611
646
|
if (record.turn === undefined) {
|
|
612
647
|
return [];
|
|
613
648
|
}
|
|
@@ -619,7 +654,7 @@ export class StateStore {
|
|
|
619
654
|
|
|
620
655
|
const rows = await this.db
|
|
621
656
|
.selectFrom('messages')
|
|
622
|
-
.select('id')
|
|
657
|
+
.select(['id', 'atc_id'])
|
|
623
658
|
.where('turn_id', '=', record.turn)
|
|
624
659
|
.where('id', '!=', record.id)
|
|
625
660
|
.where((eb) => buildOwnerFilter(eb, owner))
|
|
@@ -627,7 +662,7 @@ export class StateStore {
|
|
|
627
662
|
.orderBy(sql`rowid`, 'asc')
|
|
628
663
|
.execute();
|
|
629
664
|
|
|
630
|
-
return rows.map((row) => toMessageID(row.id));
|
|
665
|
+
return rows.map((row) => ({ id: toMessageID(row.id), atcID: toSessionID(row.atc_id) }));
|
|
631
666
|
}
|
|
632
667
|
|
|
633
668
|
async updateMessageDelivered(
|