@zgeoff/atc 2.11.0 → 2.13.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/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/store/fleet-entry.ts
CHANGED
|
@@ -4,20 +4,29 @@ import type { AgentID } from '../agents/agent-adapter';
|
|
|
4
4
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
5
5
|
import { buildOptionalBoolean } from '../shared/build-optional-boolean';
|
|
6
6
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
7
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
7
8
|
import { isRecord } from '../shared/report';
|
|
9
|
+
import type { SessionID } from '../shared/session-id';
|
|
8
10
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
11
|
+
import type { SessionWorkspace } from './workspace-materialization';
|
|
9
12
|
|
|
13
|
+
// One fleet row. The atc session id keys it and stays the same for the
|
|
14
|
+
// session's whole life, across daemon restarts and fleet restores.
|
|
10
15
|
export interface FleetEntry {
|
|
16
|
+
readonly sessionID: SessionID;
|
|
11
17
|
readonly name: string;
|
|
12
18
|
readonly cwd: string;
|
|
13
|
-
|
|
19
|
+
|
|
20
|
+
// Absent until the agent reports its own session id; a row without one
|
|
21
|
+
// restores as an exited session, since there is nothing to resume.
|
|
22
|
+
readonly agentSessionID?: AgentSessionID;
|
|
14
23
|
readonly agent: AgentID;
|
|
15
24
|
readonly pinned?: boolean;
|
|
16
25
|
readonly lastAttachedAt?: number;
|
|
17
26
|
readonly exited?: boolean;
|
|
18
27
|
|
|
19
|
-
// The
|
|
20
|
-
readonly parent?:
|
|
28
|
+
// The atc session id of the session this one is a sub-session of.
|
|
29
|
+
readonly parent?: SessionID;
|
|
21
30
|
|
|
22
31
|
// The prompt the session was spawned with.
|
|
23
32
|
readonly prompt?: string;
|
|
@@ -27,15 +36,44 @@ export interface FleetEntry {
|
|
|
27
36
|
|
|
28
37
|
// The transcript file the agent's hooks last reported for the session.
|
|
29
38
|
readonly transcriptPath?: string;
|
|
39
|
+
|
|
40
|
+
// The model and effort the session was spawned with; absent for a session
|
|
41
|
+
// that runs on the agent's configured default.
|
|
42
|
+
readonly model?: string;
|
|
43
|
+
readonly effort?: string;
|
|
44
|
+
|
|
45
|
+
// The execution target the session runs on; a row without one restores
|
|
46
|
+
// on the `local` target.
|
|
47
|
+
readonly target?: string;
|
|
48
|
+
|
|
49
|
+
// The identity the target had when the session started on it; a row
|
|
50
|
+
// without one is bound to the implicit `local` target's identity.
|
|
51
|
+
readonly targetIdentity?: string;
|
|
52
|
+
|
|
53
|
+
// What the session's workspace was materialized from, for a session
|
|
54
|
+
// spawned with a workspace source; a fleet write leaves it to the
|
|
55
|
+
// materialization record that holds it.
|
|
56
|
+
readonly workspace?: SessionWorkspace;
|
|
57
|
+
|
|
58
|
+
// The environment variable names every harness the session starts goes
|
|
59
|
+
// without, held with its ready workspace; never their values.
|
|
60
|
+
readonly withheldEnv?: readonly string[];
|
|
61
|
+
|
|
62
|
+
// What the operator asked of the harness: absent keeps it running,
|
|
63
|
+
// `sleep` keeps its host asleep, and `stop` leaves it ended.
|
|
64
|
+
readonly desired?: 'sleep' | 'stop';
|
|
65
|
+
|
|
66
|
+
// The session whose host the harness runs on: its own id, or its
|
|
67
|
+
// parent's when the two share one host. A row without one runs on a host
|
|
68
|
+
// of its own.
|
|
69
|
+
readonly hostKey?: SessionID;
|
|
30
70
|
}
|
|
31
71
|
|
|
32
72
|
export interface FleetStore {
|
|
73
|
+
readonly daemonID: DaemonID;
|
|
33
74
|
readonly loadFleet: () => Promise<FleetEntry[]>;
|
|
34
75
|
readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
|
|
35
|
-
readonly updateFleetEntry: (
|
|
36
|
-
agentSessionID: AgentSessionID,
|
|
37
|
-
fields: FleetEntryUpdate,
|
|
38
|
-
) => Promise<void>;
|
|
76
|
+
readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
// The fields a session rewrites on its own row while it runs, without
|
|
@@ -45,7 +83,21 @@ export interface FleetEntryUpdate {
|
|
|
45
83
|
readonly transcriptPath?: string;
|
|
46
84
|
}
|
|
47
85
|
|
|
48
|
-
//
|
|
86
|
+
// One entry of a legacy fleet.json, from before the fleet moved into the
|
|
87
|
+
// state store: keyed by the agent session id, with the parent link held as
|
|
88
|
+
// the parent's agent session id.
|
|
89
|
+
export interface LegacyFleetEntry {
|
|
90
|
+
readonly name: string;
|
|
91
|
+
readonly cwd: string;
|
|
92
|
+
readonly agentSessionID: AgentSessionID;
|
|
93
|
+
readonly agent: AgentID;
|
|
94
|
+
readonly pinned?: boolean;
|
|
95
|
+
readonly lastAttachedAt?: number;
|
|
96
|
+
readonly exited?: boolean;
|
|
97
|
+
readonly parent?: AgentSessionID;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A legacy fleet.json entry's keys. name, cwd, and the resolved agentSessionID are
|
|
49
101
|
// required: a row missing any of them cannot restore a session, so the whole
|
|
50
102
|
// row parses to undefined rather than a half-built entry. Fleet files
|
|
51
103
|
// written before the id key was agent-neutral carry it under its Claude-era
|
|
@@ -71,7 +123,7 @@ const FLEET_ENTRY_SCHEMA = z.preprocess(
|
|
|
71
123
|
}),
|
|
72
124
|
);
|
|
73
125
|
|
|
74
|
-
export function parseFleetEntry(raw: unknown):
|
|
126
|
+
export function parseFleetEntry(raw: unknown): LegacyFleetEntry | undefined {
|
|
75
127
|
const parsed = FLEET_ENTRY_SCHEMA.safeParse(raw);
|
|
76
128
|
|
|
77
129
|
if (!parsed.success) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a keyed effect stands. `in_progress` holds while the daemon that
|
|
3
|
+
* claimed the key runs the effect; `completed` once the effect is durable;
|
|
4
|
+
* `outcome_unknown` when a daemon stopped mid-effect and its start-up
|
|
5
|
+
* reconciliation found no trace of the effect.
|
|
6
|
+
*/
|
|
7
|
+
export type IdempotencyState = 'in_progress' | 'completed' | 'outcome_unknown';
|
|
8
|
+
|
|
9
|
+
// One idempotency key and the effect it names.
|
|
10
|
+
export interface IdempotencyRecord {
|
|
11
|
+
readonly principal: string;
|
|
12
|
+
readonly operation: string;
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly payloadHash: string;
|
|
15
|
+
readonly state: IdempotencyState;
|
|
16
|
+
|
|
17
|
+
// The id the effect was minted before it ran: the session a spawn
|
|
18
|
+
// created, or the message a send wrote.
|
|
19
|
+
readonly effectRef: string;
|
|
20
|
+
|
|
21
|
+
// The effect's answer as JSON, once completed by the daemon that ran it.
|
|
22
|
+
readonly result: string | null;
|
|
23
|
+
|
|
24
|
+
// The target the completed effect's session was bound to, as it stood
|
|
25
|
+
// then; null for a key completed without one.
|
|
26
|
+
readonly effectTarget: EffectTarget | null;
|
|
27
|
+
readonly createdAt: number;
|
|
28
|
+
readonly updatedAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A target name and the identity it held, which a replay of the key is
|
|
33
|
+
* authorized against.
|
|
34
|
+
*/
|
|
35
|
+
export interface EffectTarget {
|
|
36
|
+
readonly target: string;
|
|
37
|
+
readonly targetIdentity: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The claim a keyed request makes before its effect runs.
|
|
41
|
+
export interface IdempotencyClaim {
|
|
42
|
+
readonly principal: string;
|
|
43
|
+
readonly operation: string;
|
|
44
|
+
readonly key: string;
|
|
45
|
+
readonly payloadHash: string;
|
|
46
|
+
readonly effectRef: string;
|
|
47
|
+
readonly at: number;
|
|
48
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { AgentSessionID } from '../shared/agent-session-id';
|
|
|
2
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
3
|
|
|
4
4
|
// Which session a message belongs to: the atc id it was sent to, or the
|
|
5
|
-
// agent session id
|
|
5
|
+
// agent session id, which links rows written under an earlier atc id.
|
|
6
6
|
export interface MessageOwner {
|
|
7
7
|
readonly atcID: SessionID;
|
|
8
8
|
readonly agentSessionID?: AgentSessionID;
|
|
@@ -1,21 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { sql } from 'kysely';
|
|
2
3
|
import type { Generated, Kysely } from 'kysely';
|
|
3
4
|
import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
|
|
4
5
|
import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
|
|
6
|
+
import type { IdempotencyState } from './idempotency-record';
|
|
5
7
|
import type { MessageStatus } from './message-record';
|
|
8
|
+
import type { MaterializationPhase } from './workspace-materialization';
|
|
6
9
|
|
|
7
10
|
interface FleetTable {
|
|
8
|
-
|
|
11
|
+
session_id: string;
|
|
12
|
+
agent_session_id: string | null;
|
|
9
13
|
name: string;
|
|
10
14
|
cwd: string;
|
|
11
15
|
pinned: number;
|
|
12
16
|
last_attached: number | null;
|
|
13
17
|
agent: string;
|
|
14
18
|
exited: number;
|
|
15
|
-
|
|
19
|
+
parent_session_id: string | null;
|
|
16
20
|
prompt: string | null;
|
|
17
21
|
result: string | null;
|
|
18
22
|
transcript_path: string | null;
|
|
23
|
+
model: string | null;
|
|
24
|
+
effort: string | null;
|
|
25
|
+
target: string | null;
|
|
26
|
+
target_identity: string | null;
|
|
27
|
+
|
|
28
|
+
// What the operator asked of the session's harness: null keeps it
|
|
29
|
+
// running, `sleep` keeps its host asleep, and `stop` leaves it ended.
|
|
30
|
+
desired: string | null;
|
|
31
|
+
|
|
32
|
+
// The session whose host this session's harness runs on: its own id, or
|
|
33
|
+
// its parent's when the two share one host. Null for a row from before
|
|
34
|
+
// hosts were recorded, which runs on its own.
|
|
35
|
+
host_key: string | null;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
interface EventsTable {
|
|
@@ -27,6 +44,10 @@ interface EventsTable {
|
|
|
27
44
|
session_id: string | null;
|
|
28
45
|
kind: string | null;
|
|
29
46
|
detail: string | null;
|
|
47
|
+
|
|
48
|
+
// The id a remote session's reporter gave a report row, unique so a
|
|
49
|
+
// resent report lands once; null on every other row.
|
|
50
|
+
report_id: string | null;
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
interface SpawnHistoryTable {
|
|
@@ -39,6 +60,58 @@ interface PrefsTable {
|
|
|
39
60
|
value: string;
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
// Which daemon owns each persisted session, and under which ownership
|
|
64
|
+
// epoch. A write from a daemon whose epoch is behind the stored one is
|
|
65
|
+
// stale and rejected.
|
|
66
|
+
interface SessionOwnerTable {
|
|
67
|
+
session_id: string;
|
|
68
|
+
daemon_id: string;
|
|
69
|
+
owner_epoch: Generated<number>;
|
|
70
|
+
updated_at: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One idempotency key per principal and operation: the hash of the payload
|
|
74
|
+
// it first arrived with, where the keyed effect stands, and the effect it
|
|
75
|
+
// names. A key whose outcome is unknown never expires, so a retry of it can
|
|
76
|
+
// never start a fresh effect.
|
|
77
|
+
interface IdempotencyTable {
|
|
78
|
+
principal: string;
|
|
79
|
+
operation: string;
|
|
80
|
+
key: string;
|
|
81
|
+
payload_hash: string;
|
|
82
|
+
state: IdempotencyState;
|
|
83
|
+
effect_ref: string;
|
|
84
|
+
result: string | null;
|
|
85
|
+
created_at: number;
|
|
86
|
+
updated_at: number;
|
|
87
|
+
|
|
88
|
+
// The target, and its identity, the completed effect's session was bound
|
|
89
|
+
// to; null for a key completed without one.
|
|
90
|
+
effect_target: string | null;
|
|
91
|
+
effect_target_identity: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// One spawn's workspace on its way to the execution target, keyed by the
|
|
95
|
+
// session it is for.
|
|
96
|
+
interface WorkspaceMaterializationTable {
|
|
97
|
+
session_id: string;
|
|
98
|
+
target: string;
|
|
99
|
+
dir: string;
|
|
100
|
+
source_kind: 'path' | 'git';
|
|
101
|
+
phase: MaterializationPhase;
|
|
102
|
+
repo_url: string | null;
|
|
103
|
+
sha: string | null;
|
|
104
|
+
ref: string | null;
|
|
105
|
+
error_code: string | null;
|
|
106
|
+
started_at: number;
|
|
107
|
+
updated_at: number;
|
|
108
|
+
materialized_at: number | null;
|
|
109
|
+
|
|
110
|
+
// JSON array of the environment variable names every harness the session
|
|
111
|
+
// starts goes without; names only, never their values.
|
|
112
|
+
withheld_env: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
42
115
|
interface MessagesTable {
|
|
43
116
|
id: string;
|
|
44
117
|
atc_id: string;
|
|
@@ -66,11 +139,15 @@ export interface StateStoreSchema {
|
|
|
66
139
|
spawn_history: SpawnHistoryTable;
|
|
67
140
|
prefs: PrefsTable;
|
|
68
141
|
messages: MessagesTable;
|
|
142
|
+
session_owner: SessionOwnerTable;
|
|
143
|
+
idempotency: IdempotencyTable;
|
|
144
|
+
workspace_materialization: WorkspaceMaterializationTable;
|
|
69
145
|
}
|
|
70
146
|
|
|
71
147
|
// Every shape the fleet table has shipped with: the oldest carries only
|
|
72
148
|
// agent_session_id under its Claude-era name plus name and cwd, and each
|
|
73
|
-
// later step adds one column the daemon grew to depend on
|
|
149
|
+
// later step adds one column the daemon grew to depend on, until the
|
|
150
|
+
// rebuild that keys the table by the atc session id. events later gains
|
|
74
151
|
// columns of its own, as does messages, while spawn_history and prefs have
|
|
75
152
|
// carried one shape since they were added.
|
|
76
153
|
const MIGRATIONS: Record<string, Migration> = {
|
|
@@ -232,6 +309,134 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
232
309
|
.execute();
|
|
233
310
|
},
|
|
234
311
|
},
|
|
312
|
+
'014_add_fleet_model_effort': {
|
|
313
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
314
|
+
await db.schema.alterTable('fleet').addColumn('model', 'text').execute();
|
|
315
|
+
await db.schema.alterTable('fleet').addColumn('effort', 'text').execute();
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
'015_rebuild_fleet_keyed_by_session_id': {
|
|
319
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
320
|
+
// The ledger records this step only after it returns, so a crash in
|
|
321
|
+
// between leaves a rebuilt table behind a ledger that still lacks it.
|
|
322
|
+
const columns = await collectFleetColumns(db);
|
|
323
|
+
|
|
324
|
+
if (columns.has('session_id')) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// SQLite takes this DDL inside a transaction even though kysely's
|
|
329
|
+
// adapter declines to open one for it, so the rebuild lands whole or
|
|
330
|
+
// not at all.
|
|
331
|
+
await sql`BEGIN IMMEDIATE`.execute(db);
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await updateFleetKeyToSessionID(db);
|
|
335
|
+
|
|
336
|
+
await sql`COMMIT`.execute(db);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
await sql`ROLLBACK`.execute(db);
|
|
339
|
+
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
'016_create_session_owner': {
|
|
345
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
346
|
+
await sql`BEGIN IMMEDIATE`.execute(db);
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await createSessionOwners(db);
|
|
350
|
+
|
|
351
|
+
await sql`COMMIT`.execute(db);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await sql`ROLLBACK`.execute(db);
|
|
354
|
+
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
'017_create_idempotency': {
|
|
360
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
361
|
+
await db.schema
|
|
362
|
+
.createTable('idempotency')
|
|
363
|
+
.ifNotExists()
|
|
364
|
+
.addColumn('principal', 'text', (c) => c.notNull())
|
|
365
|
+
.addColumn('operation', 'text', (c) => c.notNull())
|
|
366
|
+
.addColumn('key', 'text', (c) => c.notNull())
|
|
367
|
+
.addColumn('payload_hash', 'text', (c) => c.notNull())
|
|
368
|
+
.addColumn('state', 'text', (c) => c.notNull())
|
|
369
|
+
.addColumn('effect_ref', 'text', (c) => c.notNull())
|
|
370
|
+
.addColumn('result', 'text')
|
|
371
|
+
.addColumn('created_at', 'integer', (c) => c.notNull())
|
|
372
|
+
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
|
373
|
+
.addPrimaryKeyConstraint('idempotency_pk', ['principal', 'operation', 'key'])
|
|
374
|
+
.execute();
|
|
375
|
+
|
|
376
|
+
await db.schema
|
|
377
|
+
.createIndex('idempotency_state_updated_at')
|
|
378
|
+
.ifNotExists()
|
|
379
|
+
.on('idempotency')
|
|
380
|
+
.columns(['state', 'updated_at'])
|
|
381
|
+
.execute();
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
'018_add_fleet_target': {
|
|
385
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
386
|
+
await db.schema.alterTable('fleet').addColumn('target', 'text').execute();
|
|
387
|
+
await db.schema.alterTable('fleet').addColumn('target_identity', 'text').execute();
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
'019_add_idempotency_effect_target': {
|
|
391
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
392
|
+
await db.schema.alterTable('idempotency').addColumn('effect_target', 'text').execute();
|
|
393
|
+
|
|
394
|
+
await db.schema
|
|
395
|
+
.alterTable('idempotency')
|
|
396
|
+
.addColumn('effect_target_identity', 'text')
|
|
397
|
+
.execute();
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
'020_create_workspace_materialization': {
|
|
401
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
402
|
+
await db.schema
|
|
403
|
+
.createTable('workspace_materialization')
|
|
404
|
+
.ifNotExists()
|
|
405
|
+
.addColumn('session_id', 'text', (c) => c.primaryKey())
|
|
406
|
+
.addColumn('target', 'text', (c) => c.notNull())
|
|
407
|
+
.addColumn('dir', 'text', (c) => c.notNull())
|
|
408
|
+
.addColumn('source_kind', 'text', (c) => c.notNull())
|
|
409
|
+
.addColumn('phase', 'text', (c) => c.notNull())
|
|
410
|
+
.addColumn('repo_url', 'text')
|
|
411
|
+
.addColumn('sha', 'text')
|
|
412
|
+
.addColumn('ref', 'text')
|
|
413
|
+
.addColumn('error_code', 'text')
|
|
414
|
+
.addColumn('started_at', 'integer', (c) => c.notNull())
|
|
415
|
+
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
|
416
|
+
.addColumn('materialized_at', 'integer')
|
|
417
|
+
.addColumn('withheld_env', 'text')
|
|
418
|
+
.execute();
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
'021_add_fleet_lifecycle': {
|
|
422
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
423
|
+
await db.schema.alterTable('fleet').addColumn('desired', 'text').execute();
|
|
424
|
+
await db.schema.alterTable('fleet').addColumn('host_key', 'text').execute();
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
'022_add_events_report_id': {
|
|
428
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
429
|
+
await db.schema.alterTable('events').addColumn('report_id', 'text').execute();
|
|
430
|
+
|
|
431
|
+
await db.schema
|
|
432
|
+
.createIndex('events_report_id')
|
|
433
|
+
.ifNotExists()
|
|
434
|
+
.unique()
|
|
435
|
+
.on('events')
|
|
436
|
+
.column('report_id')
|
|
437
|
+
.execute();
|
|
438
|
+
},
|
|
439
|
+
},
|
|
235
440
|
};
|
|
236
441
|
|
|
237
442
|
const PROVIDER: MigrationProvider = {
|
|
@@ -243,8 +448,8 @@ const PROVIDER: MigrationProvider = {
|
|
|
243
448
|
* `Migrator`, one additive step at a time. A database from before the
|
|
244
449
|
* ladder existed is recognized at whichever shape it stopped at by a
|
|
245
450
|
* baselining pass that records the steps its columns already satisfy, so
|
|
246
|
-
* only what is genuinely missing runs.
|
|
247
|
-
* a row.
|
|
451
|
+
* only what is genuinely missing runs. One step rebuilds the fleet table
|
|
452
|
+
* under a new key and carries every row across; no step drops a row.
|
|
248
453
|
*/
|
|
249
454
|
export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
|
|
250
455
|
// A baselined step can land out of order relative to a step that turned
|
|
@@ -314,8 +519,128 @@ async function recordLegacyBaseline(db: Kysely<StateStoreSchema>): Promise<void>
|
|
|
314
519
|
}
|
|
315
520
|
}
|
|
316
521
|
|
|
522
|
+
// Mints the store's daemon id when it has none, then records that daemon as
|
|
523
|
+
// the owner of every fleet row, at the first ownership epoch.
|
|
524
|
+
async function createSessionOwners(db: Kysely<StateStoreSchema>): Promise<void> {
|
|
525
|
+
await db.schema
|
|
526
|
+
.createTable('session_owner')
|
|
527
|
+
.ifNotExists()
|
|
528
|
+
.addColumn('session_id', 'text', (c) => c.primaryKey())
|
|
529
|
+
.addColumn('daemon_id', 'text', (c) => c.notNull())
|
|
530
|
+
.addColumn('owner_epoch', 'integer', (c) => c.notNull().defaultTo(1))
|
|
531
|
+
.addColumn('updated_at', 'integer', (c) => c.notNull())
|
|
532
|
+
.execute();
|
|
533
|
+
|
|
534
|
+
await db
|
|
535
|
+
.insertInto('prefs')
|
|
536
|
+
.values({ key: 'daemon_id', value: randomUUID() })
|
|
537
|
+
.onConflict((oc) => oc.column('key').doNothing())
|
|
538
|
+
.execute();
|
|
539
|
+
|
|
540
|
+
await sql`
|
|
541
|
+
INSERT OR IGNORE INTO session_owner (session_id, daemon_id, updated_at)
|
|
542
|
+
SELECT fleet.session_id, prefs.value, ${Date.now()}
|
|
543
|
+
FROM fleet JOIN prefs ON prefs.key = 'daemon_id'
|
|
544
|
+
`.execute(db);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
interface LegacyFleetIDRow {
|
|
548
|
+
old_rowid: number;
|
|
549
|
+
agent_session_id: string | null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// The columns the rebuild writes by name. Any other column a legacy table
|
|
553
|
+
// carries moves across untouched, so the rebuild never loses data a step
|
|
554
|
+
// outside this ladder added.
|
|
555
|
+
const REBUILT_FLEET_COLUMNS: ReadonlySet<string> = new Set([
|
|
556
|
+
'agent_session_id',
|
|
557
|
+
'name',
|
|
558
|
+
'cwd',
|
|
559
|
+
'pinned',
|
|
560
|
+
'last_attached',
|
|
561
|
+
'agent',
|
|
562
|
+
'exited',
|
|
563
|
+
'parent',
|
|
564
|
+
'prompt',
|
|
565
|
+
'result',
|
|
566
|
+
'transcript_path',
|
|
567
|
+
'model',
|
|
568
|
+
'effort',
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
// Gives every row a minted atc session id and moves the sub-session link
|
|
572
|
+
// from the parent's agent session id to the parent's atc session id. A link
|
|
573
|
+
// to an agent session id no row holds becomes no link.
|
|
574
|
+
async function updateFleetKeyToSessionID(db: Kysely<StateStoreSchema>): Promise<void> {
|
|
575
|
+
const columns = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
|
|
576
|
+
|
|
577
|
+
const extras = columns.rows.filter((column) => !REBUILT_FLEET_COLUMNS.has(column.name));
|
|
578
|
+
|
|
579
|
+
// Keyed by rowid: SQLite lets a text primary key hold NULL, so a legacy
|
|
580
|
+
// row can lack the agent session id the mapping would otherwise key on.
|
|
581
|
+
await sql`CREATE TEMP TABLE fleet_ids (old_rowid INTEGER PRIMARY KEY, agent_session_id TEXT, session_id TEXT NOT NULL)`.execute(
|
|
582
|
+
db,
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
const rows =
|
|
586
|
+
await sql<LegacyFleetIDRow>`SELECT rowid AS old_rowid, agent_session_id FROM fleet`.execute(db);
|
|
587
|
+
|
|
588
|
+
for (const row of rows.rows) {
|
|
589
|
+
await sql`INSERT INTO fleet_ids (old_rowid, agent_session_id, session_id) VALUES (${row.old_rowid}, ${row.agent_session_id}, ${randomUUID()})`.execute(
|
|
590
|
+
db,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const extraDefinitions = extras.map(
|
|
595
|
+
(column) => sql`, ${sql.ref(column.name)} ${sql.raw(column.type)}`,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const extraTargets = extras.map((column) => sql`, ${sql.ref(column.name)}`);
|
|
599
|
+
const extraSources = extras.map((column) => sql`, ${sql.ref(`f.${column.name}`)}`);
|
|
600
|
+
|
|
601
|
+
await sql`
|
|
602
|
+
CREATE TABLE fleet_rebuilt (
|
|
603
|
+
session_id TEXT PRIMARY KEY,
|
|
604
|
+
agent_session_id TEXT UNIQUE,
|
|
605
|
+
name TEXT NOT NULL,
|
|
606
|
+
cwd TEXT NOT NULL,
|
|
607
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
608
|
+
last_attached INTEGER,
|
|
609
|
+
agent TEXT NOT NULL DEFAULT 'claude',
|
|
610
|
+
exited INTEGER NOT NULL DEFAULT 0,
|
|
611
|
+
parent_session_id TEXT,
|
|
612
|
+
prompt TEXT,
|
|
613
|
+
result TEXT,
|
|
614
|
+
transcript_path TEXT,
|
|
615
|
+
model TEXT,
|
|
616
|
+
effort TEXT
|
|
617
|
+
${sql.join(extraDefinitions, sql``)}
|
|
618
|
+
)
|
|
619
|
+
`.execute(db);
|
|
620
|
+
|
|
621
|
+
await sql`
|
|
622
|
+
INSERT INTO fleet_rebuilt (
|
|
623
|
+
session_id, agent_session_id, name, cwd, pinned, last_attached, agent, exited,
|
|
624
|
+
parent_session_id, prompt, result, transcript_path, model, effort
|
|
625
|
+
${sql.join(extraTargets, sql``)}
|
|
626
|
+
)
|
|
627
|
+
SELECT
|
|
628
|
+
ids.session_id, f.agent_session_id, f.name, f.cwd, f.pinned, f.last_attached, f.agent,
|
|
629
|
+
f.exited, parents.session_id, f.prompt, f.result, f.transcript_path, f.model, f.effort
|
|
630
|
+
${sql.join(extraSources, sql``)}
|
|
631
|
+
FROM fleet f
|
|
632
|
+
JOIN fleet_ids ids ON ids.old_rowid = f.rowid
|
|
633
|
+
LEFT JOIN fleet_ids parents ON parents.agent_session_id = f.parent
|
|
634
|
+
`.execute(db);
|
|
635
|
+
|
|
636
|
+
await sql`DROP TABLE fleet`.execute(db);
|
|
637
|
+
await sql`ALTER TABLE fleet_rebuilt RENAME TO fleet`.execute(db);
|
|
638
|
+
await sql`DROP TABLE fleet_ids`.execute(db);
|
|
639
|
+
}
|
|
640
|
+
|
|
317
641
|
interface ColumnInfoRow {
|
|
318
642
|
name: string;
|
|
643
|
+
type: string;
|
|
319
644
|
}
|
|
320
645
|
|
|
321
646
|
async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
|