@zgeoff/atc 2.15.1 → 2.16.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 +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +15 -4
- package/src/daemon/broker-authority-error.ts +41 -0
- 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/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +202 -51
- package/src/daemon/daemon-context.ts +63 -12
- package/src/daemon/daemon.ts +258 -90
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/sessions.ts +62 -22
- package/src/daemon/tap-registry.ts +43 -5
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/protocol/hook-event.ts +6 -2
- package/src/statusline.ts +8 -6
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +44 -9
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { BrokerAuthorityError } from './broker-authority-error';
|
|
2
|
+
import type { ImpPort } from './imp-port';
|
|
3
|
+
import { verifyTokenImpAuthority } from './verify-token-imp-authority';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a provisioning call is about to touch: the imps under their actual
|
|
7
|
+
* names, as the target's runtime namespace builds them, and every secret
|
|
8
|
+
* the binding grants them.
|
|
9
|
+
*/
|
|
10
|
+
export interface BrokerActivation {
|
|
11
|
+
readonly impNames: readonly string[];
|
|
12
|
+
readonly secrets: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The gate before atc grants or relies on a brokered credential. It reads
|
|
17
|
+
* impd's features, then the token's identity, and writes nothing, so a
|
|
18
|
+
* refusal leaves impd as it was. It rejects unless impd has both grantable
|
|
19
|
+
* tokens and secret rebinds, the token may manage each imp and reaches no
|
|
20
|
+
* imp outside the namespace whose imp names start with the prefix, and the
|
|
21
|
+
* token may grant every bound secret.
|
|
22
|
+
* Whether each secret's rules match the binding is a separate comparison.
|
|
23
|
+
*/
|
|
24
|
+
export async function verifyBrokerAuthority(
|
|
25
|
+
port: Pick<ImpPort, 'readFeatures' | 'readIdentity'>,
|
|
26
|
+
activation: BrokerActivation,
|
|
27
|
+
impPrefix: string,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const features = await port.readFeatures();
|
|
30
|
+
|
|
31
|
+
if (!features.grantableTokens || !features.secretRebind) {
|
|
32
|
+
throw new BrokerAuthorityError(
|
|
33
|
+
'auth_impd_too_old',
|
|
34
|
+
'impd lacks grantable tokens or secret rebinds; it must be 0.27.0 or later',
|
|
35
|
+
{ grantableTokens: features.grantableTokens, secretRebind: features.secretRebind },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const identity = await port.readIdentity();
|
|
40
|
+
|
|
41
|
+
verifyTokenImpAuthority(identity, activation.impNames, impPrefix);
|
|
42
|
+
|
|
43
|
+
const missing = activation.secrets.filter((secret) => !identity.grantable.includes(secret));
|
|
44
|
+
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
throw new BrokerAuthorityError(
|
|
47
|
+
'auth_secret_not_grantable',
|
|
48
|
+
`impd token ${identity.name} cannot grant ${missing.join(', ')}`,
|
|
49
|
+
{ token: identity.name, grantable: identity.grantable, missing },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { BrokerAuthorityError } from './broker-authority-error';
|
|
2
|
+
import type { ImpPort, ImpView } from './imp-port';
|
|
3
|
+
import { verifyTokenImpAuthority } from './verify-token-imp-authority';
|
|
4
|
+
|
|
5
|
+
// The imp a binding recorded when atc made it.
|
|
6
|
+
export interface RecordedImp {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The check before atc destroys a session's imp or revokes its grants.
|
|
13
|
+
* It asks that the token may manage the recorded imp, under imp patterns
|
|
14
|
+
* that stay inside the namespace whose imp names start with the prefix,
|
|
15
|
+
* and that the imp under that
|
|
16
|
+
* name is still the one recorded. It never asks that a secret's rules
|
|
17
|
+
* still match or that every grant is still in place, so a rotated, rebound
|
|
18
|
+
* or deleted secret never stops atc removing access. Resolves to the imp,
|
|
19
|
+
* or to null when impd confirms no imp holds the name, which needs no
|
|
20
|
+
* cleanup; an imp with another id rejects.
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyCleanupAuthority(
|
|
23
|
+
port: Pick<ImpPort, 'readIdentity' | 'readImp'>,
|
|
24
|
+
recorded: RecordedImp,
|
|
25
|
+
impPrefix: string,
|
|
26
|
+
): Promise<ImpView | null> {
|
|
27
|
+
const identity = await port.readIdentity();
|
|
28
|
+
|
|
29
|
+
// Checked first, so impd's answer for the name comes from a token that
|
|
30
|
+
// can see the imp, and a missing imp means it is gone.
|
|
31
|
+
verifyTokenImpAuthority(identity, [recorded.name], impPrefix);
|
|
32
|
+
|
|
33
|
+
const imp = await port.readImp(recorded.name);
|
|
34
|
+
|
|
35
|
+
if (imp !== null && imp.id !== recorded.id) {
|
|
36
|
+
throw new BrokerAuthorityError(
|
|
37
|
+
'auth_runtime_mismatch',
|
|
38
|
+
`imp ${recorded.name} is no longer the imp atc made`,
|
|
39
|
+
{ imp: recorded.name, recordedID: recorded.id, actualID: imp.id },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return imp;
|
|
44
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { BrokerAuthorityError } from './broker-authority-error';
|
|
2
|
+
import type { ImpIdentity } from './imp-port';
|
|
3
|
+
import { isImpNameAllowed } from './is-imp-name-allowed';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rejects unless the token may manage every named imp and reaches no imp
|
|
7
|
+
* outside atc's namespace: scope `manage`, imp patterns rather than none,
|
|
8
|
+
* and each pattern contained in the namespace prefix, so the literal text
|
|
9
|
+
* before its first `*`, or the whole pattern when it has none, starts with
|
|
10
|
+
* the prefix. A token with any pattern beyond the prefix is refused
|
|
11
|
+
* outright, however few names that pattern reaches, never used in place of
|
|
12
|
+
* a contained one. Each name must then match a pattern. The prefix is the
|
|
13
|
+
* literal start of every imp name the target's runtime namespace builds,
|
|
14
|
+
* and the names are the imps the call touches under those built names.
|
|
15
|
+
*/
|
|
16
|
+
export function verifyTokenImpAuthority(
|
|
17
|
+
identity: ImpIdentity,
|
|
18
|
+
impNames: readonly string[],
|
|
19
|
+
impPrefix: string,
|
|
20
|
+
): void {
|
|
21
|
+
assertImpPrefix(impPrefix);
|
|
22
|
+
|
|
23
|
+
if (identity.scope !== 'manage') {
|
|
24
|
+
throw new BrokerAuthorityError(
|
|
25
|
+
'auth_token_scope',
|
|
26
|
+
`impd token ${identity.name} cannot manage imps`,
|
|
27
|
+
{
|
|
28
|
+
token: identity.name,
|
|
29
|
+
scope: identity.scope,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const patterns = identity.imps;
|
|
35
|
+
|
|
36
|
+
if (patterns === null || !patterns.every((pattern) => isPatternContained(pattern, impPrefix))) {
|
|
37
|
+
throw new BrokerAuthorityError(
|
|
38
|
+
'auth_token_too_broad',
|
|
39
|
+
`impd token ${identity.name} can reach imps outside atc's namespace, whose imp names start with ${impPrefix}`,
|
|
40
|
+
{
|
|
41
|
+
token: identity.name,
|
|
42
|
+
imps: patterns,
|
|
43
|
+
offending:
|
|
44
|
+
patterns === null
|
|
45
|
+
? null
|
|
46
|
+
: patterns.filter((pattern) => !isPatternContained(pattern, impPrefix)),
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const outside = impNames.filter((name) => !isImpNameAllowed(patterns, name));
|
|
52
|
+
|
|
53
|
+
if (outside.length > 0) {
|
|
54
|
+
throw new BrokerAuthorityError(
|
|
55
|
+
'auth_imp_out_of_scope',
|
|
56
|
+
`impd token ${identity.name} cannot manage ${outside.join(', ')}`,
|
|
57
|
+
{ token: identity.name, imps: patterns, outside },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// An empty prefix would contain every pattern, `*` included.
|
|
63
|
+
function assertImpPrefix(impPrefix: string): void {
|
|
64
|
+
if (impPrefix === '') {
|
|
65
|
+
throw new Error('the imp name prefix of a runtime namespace must not be empty');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Every name a pattern matches starts with the text before its first `*`.
|
|
70
|
+
function isPatternContained(pattern: string, impPrefix: string): boolean {
|
|
71
|
+
const [literal = ''] = pattern.split('*');
|
|
72
|
+
|
|
73
|
+
return literal.startsWith(impPrefix);
|
|
74
|
+
}
|
package/src/hook-report.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { isRecord, sendReport } from './shared/report';
|
|
|
4
4
|
/**
|
|
5
5
|
* Runs as a hook inside wrangled sessions. Reads the hook event from stdin
|
|
6
6
|
* (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
|
|
7
|
-
* event name to the atc unix socket
|
|
8
|
-
*
|
|
7
|
+
* event name to the atc unix socket, with the agent id the hook command
|
|
8
|
+
* gave it, so the daemon can tell a nested harness's report from the
|
|
9
|
+
* session's own. Always exits 0 so it never blocks the session it reports
|
|
10
|
+
* on.
|
|
9
11
|
*/
|
|
10
|
-
export async function runHookReport(): Promise<void> {
|
|
12
|
+
export async function runHookReport(agent: string): Promise<void> {
|
|
11
13
|
const sock = process.env['ATC_SOCKET'];
|
|
12
14
|
const atcId = process.env['ATC_SESSION_ID'];
|
|
13
15
|
|
|
@@ -26,7 +28,7 @@ export async function runHookReport(): Promise<void> {
|
|
|
26
28
|
|
|
27
29
|
const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
|
|
28
30
|
const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
|
|
29
|
-
const line = `${JSON.stringify({ atcId, event, payload })}\n`;
|
|
31
|
+
const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event, payload })}\n`;
|
|
30
32
|
|
|
31
33
|
await sendReport(sock, line, 2000);
|
|
32
34
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import type { AgentID } from '../shared/agent-id';
|
|
1
2
|
import type { SessionID } from '../shared/session-id';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* One line a hook reporter sends: the atc session it reports on, the agent
|
|
5
|
-
* hook
|
|
5
|
+
* One line a hook reporter sends: the atc session it reports on, the agent
|
|
6
|
+
* whose hook command sent it, the agent's hook event name, and the hook's
|
|
7
|
+
* payload as the agent gave it. The agent is absent on a line from a hook
|
|
8
|
+
* command that carries none.
|
|
6
9
|
*/
|
|
7
10
|
export interface HookEvent {
|
|
8
11
|
atcId: SessionID;
|
|
12
|
+
agent?: AgentID;
|
|
9
13
|
event: string;
|
|
10
14
|
payload: Record<string, unknown>;
|
|
11
15
|
}
|
package/src/statusline.ts
CHANGED
|
@@ -8,10 +8,11 @@ import { isRecord, sendReport } from './shared/report';
|
|
|
8
8
|
/**
|
|
9
9
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
|
10
10
|
* user's own statusline (from ~/.claude/settings.json), appends the atc fleet
|
|
11
|
-
* segment, and heartbeats the session id back to the atc socket
|
|
12
|
-
* exits 0 so it never breaks the
|
|
11
|
+
* segment, and heartbeats the session id back to the atc socket, with the
|
|
12
|
+
* agent id the command gave it. Always exits 0 so it never breaks the
|
|
13
|
+
* session it renders for.
|
|
13
14
|
*/
|
|
14
|
-
export async function runStatusline(): Promise<void> {
|
|
15
|
+
export async function runStatusline(agent: string): Promise<void> {
|
|
15
16
|
const raw = await new Response(Bun.stdin.stream()).text();
|
|
16
17
|
|
|
17
18
|
const sock = process.env['ATC_SOCKET'];
|
|
@@ -28,7 +29,7 @@ export async function runStatusline(): Promise<void> {
|
|
|
28
29
|
}
|
|
29
30
|
} catch {}
|
|
30
31
|
|
|
31
|
-
const line = `${JSON.stringify({ atcId, event: 'Statusline', payload })}\n`;
|
|
32
|
+
const line = `${JSON.stringify({ atcId, ...(agent === '' ? {} : { agent }), event: 'Statusline', payload })}\n`;
|
|
32
33
|
|
|
33
34
|
await sendReport(sock, line, 500);
|
|
34
35
|
}
|
|
@@ -128,7 +129,8 @@ async function readOwnSegment(sock: string): Promise<string> {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// A user statusline that is atc's own injected command would chain into
|
|
131
|
-
// itself; the injected command
|
|
132
|
+
// itself; the injected command ends with the bare subcommand, or with the
|
|
133
|
+
// subcommand and its agent flag.
|
|
132
134
|
function isSelfCommand(cmd: string): boolean {
|
|
133
|
-
return cmd.includes('statusline.ts') ||
|
|
135
|
+
return cmd.includes('statusline.ts') || /\sstatusline(?:\s+--agent\s.*)?$/u.test(cmd);
|
|
134
136
|
}
|
|
@@ -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(
|