@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/state-store.ts
CHANGED
|
@@ -1,36 +1,47 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
4
|
-
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
|
|
5
|
+
import type { Expression, ExpressionBuilder, SqlBool, Transaction } from 'kysely';
|
|
5
6
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
7
|
import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
|
|
7
8
|
import type { HookEvent } from '../daemon/hooks';
|
|
9
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
8
10
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
11
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
9
12
|
import type { MessageID } from '../shared/message-id';
|
|
10
13
|
import type { SessionID } from '../shared/session-id';
|
|
11
14
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
15
|
+
import { toDaemonID } from '../shared/to-daemon-id';
|
|
12
16
|
import { toMessageID } from '../shared/to-message-id';
|
|
13
17
|
import { toSessionID } from '../shared/to-session-id';
|
|
14
18
|
import { BunSqliteDriver } from './bun-sqlite-driver';
|
|
15
19
|
import { parseFleetEntry } from './fleet-entry';
|
|
16
|
-
import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
|
|
20
|
+
import type { FleetEntry, FleetEntryUpdate, LegacyFleetEntry } from './fleet-entry';
|
|
21
|
+
import type { EffectTarget, IdempotencyClaim, IdempotencyRecord } from './idempotency-record';
|
|
17
22
|
import type { MessageOwner } from './message-owner';
|
|
18
23
|
import type { MessageRecord } from './message-record';
|
|
19
24
|
import { runMigrations } from './run-migrations';
|
|
20
25
|
import type { StateStoreSchema } from './run-migrations';
|
|
21
26
|
import type { TrailEntry } from './trail-entry';
|
|
27
|
+
import type {
|
|
28
|
+
MaterializationUpdate,
|
|
29
|
+
SessionWorkspace,
|
|
30
|
+
WorkspaceMaterialization,
|
|
31
|
+
} from './workspace-materialization';
|
|
22
32
|
|
|
23
33
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
24
34
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
|
-
*
|
|
28
|
-
* under
|
|
29
|
-
*
|
|
37
|
+
* Some sessions' slice of the event trail: rows under their atc ids, plus
|
|
38
|
+
* rows under their agent session ids, since rows written before atc session
|
|
39
|
+
* ids stayed stable across restores carry an earlier atc id. A scope of no
|
|
40
|
+
* sessions matches no row.
|
|
30
41
|
*/
|
|
31
42
|
export interface EventScope {
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
43
|
+
readonly atcIDs: readonly SessionID[];
|
|
44
|
+
readonly agentSessionIDs: readonly AgentSessionID[];
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export interface StoredEvent {
|
|
@@ -70,9 +81,14 @@ export class StateStore {
|
|
|
70
81
|
|
|
71
82
|
private readonly db: Kysely<StateStoreSchema>;
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
// The daemon this store's fleet rows belong to, minted once by the
|
|
85
|
+
// migration that created session ownership.
|
|
86
|
+
readonly daemonID: DaemonID;
|
|
87
|
+
|
|
88
|
+
private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
|
|
74
89
|
this.sqlite = sqlite;
|
|
75
90
|
this.db = db;
|
|
91
|
+
this.daemonID = daemonID;
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
// Migrations run statements that cannot happen inside a constructor, so
|
|
@@ -93,7 +109,13 @@ export class StateStore {
|
|
|
93
109
|
|
|
94
110
|
await runMigrations(db);
|
|
95
111
|
|
|
96
|
-
const
|
|
112
|
+
const daemonIDRow = await db
|
|
113
|
+
.selectFrom('prefs')
|
|
114
|
+
.select('value')
|
|
115
|
+
.where('key', '=', 'daemon_id')
|
|
116
|
+
.executeTakeFirstOrThrow();
|
|
117
|
+
|
|
118
|
+
const store = new StateStore(sqlite, db, toDaemonID(daemonIDRow.value));
|
|
97
119
|
|
|
98
120
|
if (legacyFleetPath !== undefined) {
|
|
99
121
|
await store.adoptLegacyFleet(legacyFleetPath);
|
|
@@ -102,21 +124,48 @@ export class StateStore {
|
|
|
102
124
|
return store;
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// Only the rows this daemon owns: another daemon's sessions are its own
|
|
128
|
+
// to restore.
|
|
105
129
|
async loadFleet(): Promise<FleetEntry[]> {
|
|
106
130
|
const rows = await this.db
|
|
107
131
|
.selectFrom('fleet')
|
|
132
|
+
|
|
133
|
+
// A session's ready workspace comes back with its row.
|
|
134
|
+
.leftJoin('workspace_materialization as workspace', (join) =>
|
|
135
|
+
join
|
|
136
|
+
.onRef('workspace.session_id', '=', 'fleet.session_id')
|
|
137
|
+
.on('workspace.phase', '=', 'ready'),
|
|
138
|
+
)
|
|
139
|
+
.where('fleet.session_id', 'in', (eb) =>
|
|
140
|
+
eb.selectFrom('session_owner').select('session_id').where('daemon_id', '=', this.daemonID),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// Stored order, which a restore keeps for sessions with no recency.
|
|
144
|
+
.orderBy(sql`fleet.rowid`)
|
|
108
145
|
.select([
|
|
109
|
-
'
|
|
110
|
-
'
|
|
111
|
-
'
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
146
|
+
'fleet.session_id',
|
|
147
|
+
'fleet.agent_session_id',
|
|
148
|
+
'fleet.name',
|
|
149
|
+
'fleet.cwd',
|
|
150
|
+
'fleet.pinned',
|
|
151
|
+
'fleet.last_attached',
|
|
152
|
+
'fleet.agent',
|
|
153
|
+
'fleet.exited',
|
|
154
|
+
'fleet.parent_session_id',
|
|
155
|
+
'fleet.prompt',
|
|
156
|
+
'fleet.result',
|
|
157
|
+
'fleet.transcript_path',
|
|
158
|
+
'fleet.model',
|
|
159
|
+
'fleet.effort',
|
|
160
|
+
'fleet.target',
|
|
161
|
+
'fleet.target_identity',
|
|
162
|
+
'workspace.repo_url',
|
|
163
|
+
'workspace.sha',
|
|
164
|
+
'workspace.ref',
|
|
165
|
+
'workspace.materialized_at',
|
|
166
|
+
'workspace.withheld_env',
|
|
167
|
+
'fleet.desired',
|
|
168
|
+
'fleet.host_key',
|
|
120
169
|
])
|
|
121
170
|
.execute();
|
|
122
171
|
|
|
@@ -124,17 +173,28 @@ export class StateStore {
|
|
|
124
173
|
|
|
125
174
|
for (const row of rows) {
|
|
126
175
|
entries.push({
|
|
127
|
-
|
|
176
|
+
sessionID: toSessionID(row.session_id),
|
|
128
177
|
name: row.name,
|
|
129
178
|
cwd: row.cwd,
|
|
179
|
+
...(row.agent_session_id === null
|
|
180
|
+
? {}
|
|
181
|
+
: { agentSessionID: toAgentSessionID(row.agent_session_id) }),
|
|
130
182
|
agent: toAgentID(row.agent),
|
|
131
183
|
...(row.pinned === 0 ? {} : { pinned: true }),
|
|
132
184
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
133
185
|
...(row.exited === 0 ? {} : { exited: true }),
|
|
134
|
-
...(row.
|
|
186
|
+
...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
|
|
135
187
|
...(row.prompt === null ? {} : { prompt: row.prompt }),
|
|
136
188
|
...(row.result === null ? {} : { result: row.result }),
|
|
137
189
|
...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
|
|
190
|
+
...(row.model === null ? {} : { model: row.model }),
|
|
191
|
+
...(row.effort === null ? {} : { effort: row.effort }),
|
|
192
|
+
...(row.target === null ? {} : { target: row.target }),
|
|
193
|
+
...(row.target_identity === null ? {} : { targetIdentity: row.target_identity }),
|
|
194
|
+
...buildWorkspaceField(row),
|
|
195
|
+
...buildWithheldEnvField(row.withheld_env),
|
|
196
|
+
...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
|
|
197
|
+
...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
|
|
138
198
|
});
|
|
139
199
|
}
|
|
140
200
|
|
|
@@ -164,27 +224,67 @@ export class StateStore {
|
|
|
164
224
|
return recency;
|
|
165
225
|
}
|
|
166
226
|
|
|
227
|
+
// Replaces this daemon's rows wholesale and leaves every other daemon's
|
|
228
|
+
// rows as they stand. A session another daemon owns, or one whose stored
|
|
229
|
+
// ownership epoch has moved past this daemon's, rejects the whole write
|
|
230
|
+
// with stale_epoch.
|
|
167
231
|
async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
|
|
168
|
-
|
|
169
|
-
await trx.deleteFrom('fleet').execute();
|
|
232
|
+
const kept = buildFleetWithoutReplacedRows(entries);
|
|
170
233
|
|
|
171
|
-
|
|
234
|
+
await this.db.transaction().execute(async (trx) => {
|
|
235
|
+
await requireCurrentOwnership(
|
|
236
|
+
trx,
|
|
237
|
+
this.daemonID,
|
|
238
|
+
entries.map((entry) => entry.sessionID),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await trx
|
|
242
|
+
.deleteFrom('fleet')
|
|
243
|
+
.where('session_id', 'in', (eb) =>
|
|
244
|
+
eb
|
|
245
|
+
.selectFrom('session_owner')
|
|
246
|
+
.select('session_id')
|
|
247
|
+
.where('daemon_id', '=', this.daemonID),
|
|
248
|
+
)
|
|
249
|
+
.execute();
|
|
250
|
+
|
|
251
|
+
await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
|
|
252
|
+
|
|
253
|
+
const updatedAt = Date.now();
|
|
254
|
+
|
|
255
|
+
for (const entry of kept) {
|
|
172
256
|
await trx
|
|
173
257
|
.insertInto('fleet')
|
|
174
258
|
.values({
|
|
175
|
-
|
|
259
|
+
session_id: entry.sessionID,
|
|
260
|
+
agent_session_id: entry.agentSessionID ?? null,
|
|
176
261
|
name: entry.name,
|
|
177
262
|
cwd: entry.cwd,
|
|
178
263
|
pinned: entry.pinned === true ? 1 : 0,
|
|
179
264
|
last_attached: entry.lastAttachedAt ?? null,
|
|
180
265
|
agent: entry.agent,
|
|
181
266
|
exited: entry.exited === true ? 1 : 0,
|
|
182
|
-
|
|
267
|
+
parent_session_id: entry.parent ?? null,
|
|
183
268
|
prompt: entry.prompt ?? null,
|
|
184
269
|
result: entry.result ?? null,
|
|
185
270
|
transcript_path: entry.transcriptPath ?? null,
|
|
271
|
+
model: entry.model ?? null,
|
|
272
|
+
effort: entry.effort ?? null,
|
|
273
|
+
target: entry.target ?? null,
|
|
274
|
+
target_identity: entry.targetIdentity ?? null,
|
|
275
|
+
desired: entry.desired ?? null,
|
|
276
|
+
host_key: entry.hostKey ?? null,
|
|
277
|
+
})
|
|
278
|
+
.execute();
|
|
279
|
+
|
|
280
|
+
await trx
|
|
281
|
+
.insertInto('session_owner')
|
|
282
|
+
.values({
|
|
283
|
+
session_id: entry.sessionID,
|
|
284
|
+
daemon_id: this.daemonID,
|
|
285
|
+
owner_epoch: OWNER_EPOCH,
|
|
286
|
+
updated_at: updatedAt,
|
|
186
287
|
})
|
|
187
|
-
.orReplace()
|
|
188
288
|
.execute();
|
|
189
289
|
}
|
|
190
290
|
});
|
|
@@ -193,7 +293,7 @@ export class StateStore {
|
|
|
193
293
|
// Rewrites fields on one existing row and leaves every other row as it
|
|
194
294
|
// stands. A session without a row yet is a no-op: the next fleet write
|
|
195
295
|
// inserts it with these fields.
|
|
196
|
-
async updateFleetEntry(
|
|
296
|
+
async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
|
|
197
297
|
const values = {
|
|
198
298
|
...(fields.result === undefined ? {} : { result: fields.result }),
|
|
199
299
|
...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
|
|
@@ -203,11 +303,11 @@ export class StateStore {
|
|
|
203
303
|
return;
|
|
204
304
|
}
|
|
205
305
|
|
|
206
|
-
await this.db
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
.where('
|
|
210
|
-
|
|
306
|
+
await this.db.transaction().execute(async (trx) => {
|
|
307
|
+
await requireCurrentOwnership(trx, this.daemonID, [sessionID]);
|
|
308
|
+
|
|
309
|
+
await trx.updateTable('fleet').set(values).where('session_id', '=', sessionID).execute();
|
|
310
|
+
});
|
|
211
311
|
}
|
|
212
312
|
|
|
213
313
|
async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
|
|
@@ -231,8 +331,10 @@ export class StateStore {
|
|
|
231
331
|
.execute();
|
|
232
332
|
}
|
|
233
333
|
|
|
234
|
-
|
|
235
|
-
|
|
334
|
+
// Returns whether the entry was written: a report whose id the trail
|
|
335
|
+
// already holds is left out.
|
|
336
|
+
async recordTrailEntry(entry: TrailEntry): Promise<boolean> {
|
|
337
|
+
const result = await this.db
|
|
236
338
|
.insertInto('events')
|
|
237
339
|
.values({
|
|
238
340
|
ts: new Date(entry.at).toISOString(),
|
|
@@ -244,8 +346,12 @@ export class StateStore {
|
|
|
244
346
|
session_id: entry.agentSessionID,
|
|
245
347
|
kind: entry.kind,
|
|
246
348
|
detail: entry.detail,
|
|
349
|
+
report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
|
|
247
350
|
})
|
|
248
|
-
.
|
|
351
|
+
.onConflict((oc) => oc.column('report_id').doNothing())
|
|
352
|
+
.executeTakeFirst();
|
|
353
|
+
|
|
354
|
+
return result.numInsertedOrUpdatedRows !== 0n;
|
|
249
355
|
}
|
|
250
356
|
|
|
251
357
|
async collectEventsAfter(
|
|
@@ -282,8 +388,8 @@ export class StateStore {
|
|
|
282
388
|
return buildStoredEvents(rows).toReversed();
|
|
283
389
|
}
|
|
284
390
|
|
|
285
|
-
// Matches the session by either id, since
|
|
286
|
-
//
|
|
391
|
+
// Matches the session by either id, since rows written before atc session
|
|
392
|
+
// ids stayed stable across restores carry an earlier atc id.
|
|
287
393
|
async loadLastActivityAt(
|
|
288
394
|
atcID: SessionID,
|
|
289
395
|
agentSessionID: AgentSessionID | undefined,
|
|
@@ -496,6 +602,215 @@ export class StateStore {
|
|
|
496
602
|
.execute();
|
|
497
603
|
}
|
|
498
604
|
|
|
605
|
+
// Records a key in progress unless one is already held under the same
|
|
606
|
+
// principal and operation. Returns null when this call claimed the key,
|
|
607
|
+
// else the record the key already holds.
|
|
608
|
+
async claimIdempotencyKey(claim: IdempotencyClaim): Promise<IdempotencyRecord | null> {
|
|
609
|
+
const inserted = await this.db
|
|
610
|
+
.insertInto('idempotency')
|
|
611
|
+
.values({
|
|
612
|
+
principal: claim.principal,
|
|
613
|
+
operation: claim.operation,
|
|
614
|
+
key: claim.key,
|
|
615
|
+
payload_hash: claim.payloadHash,
|
|
616
|
+
state: 'in_progress',
|
|
617
|
+
effect_ref: claim.effectRef,
|
|
618
|
+
result: null,
|
|
619
|
+
created_at: claim.at,
|
|
620
|
+
updated_at: claim.at,
|
|
621
|
+
effect_target: null,
|
|
622
|
+
effect_target_identity: null,
|
|
623
|
+
})
|
|
624
|
+
.onConflict((oc) => oc.columns(['principal', 'operation', 'key']).doNothing())
|
|
625
|
+
.returning('key')
|
|
626
|
+
.executeTakeFirst();
|
|
627
|
+
|
|
628
|
+
if (inserted !== undefined) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const row = await this.db
|
|
633
|
+
.selectFrom('idempotency')
|
|
634
|
+
.selectAll()
|
|
635
|
+
.where('principal', '=', claim.principal)
|
|
636
|
+
.where('operation', '=', claim.operation)
|
|
637
|
+
.where('key', '=', claim.key)
|
|
638
|
+
.executeTakeFirstOrThrow();
|
|
639
|
+
|
|
640
|
+
return toIdempotencyRecord(row);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async updateIdempotencyCompleted(
|
|
644
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
645
|
+
result: string,
|
|
646
|
+
at: number,
|
|
647
|
+
effectTarget: EffectTarget | null = null,
|
|
648
|
+
): Promise<void> {
|
|
649
|
+
await this.db
|
|
650
|
+
.updateTable('idempotency')
|
|
651
|
+
.set({
|
|
652
|
+
state: 'completed',
|
|
653
|
+
result,
|
|
654
|
+
updated_at: at,
|
|
655
|
+
effect_target: effectTarget?.target ?? null,
|
|
656
|
+
effect_target_identity: effectTarget?.targetIdentity ?? null,
|
|
657
|
+
})
|
|
658
|
+
.where('principal', '=', record.principal)
|
|
659
|
+
.where('operation', '=', record.operation)
|
|
660
|
+
.where('key', '=', record.key)
|
|
661
|
+
.execute();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Keeps a claim whose effect may still stand as outcome_unknown, so a
|
|
665
|
+
// retry answers from it and never runs the effect again.
|
|
666
|
+
async updateIdempotencyOutcomeUnknown(
|
|
667
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
668
|
+
at: number,
|
|
669
|
+
): Promise<void> {
|
|
670
|
+
await this.db
|
|
671
|
+
.updateTable('idempotency')
|
|
672
|
+
.set({ state: 'outcome_unknown', updated_at: at })
|
|
673
|
+
.where('principal', '=', record.principal)
|
|
674
|
+
.where('operation', '=', record.operation)
|
|
675
|
+
.where('key', '=', record.key)
|
|
676
|
+
.execute();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Drops a claim whose effect never started, so a retry runs it fresh.
|
|
680
|
+
async removeIdempotencyKey(
|
|
681
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
682
|
+
): Promise<void> {
|
|
683
|
+
await this.db
|
|
684
|
+
.deleteFrom('idempotency')
|
|
685
|
+
.where('principal', '=', record.principal)
|
|
686
|
+
.where('operation', '=', record.operation)
|
|
687
|
+
.where('key', '=', record.key)
|
|
688
|
+
.execute();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Runs once as a daemon starts, before it serves a request: every key
|
|
692
|
+
// still in progress belonged to a daemon that stopped mid-effect, so its
|
|
693
|
+
// outcome is unknown. A key whose effect left its row behind completes;
|
|
694
|
+
// one whose effect left no trace stays unknown.
|
|
695
|
+
async reconcileIdempotencyKeys(at: number): Promise<void> {
|
|
696
|
+
await this.db.transaction().execute(async (trx) => {
|
|
697
|
+
await trx
|
|
698
|
+
.updateTable('idempotency')
|
|
699
|
+
.set({ state: 'outcome_unknown', updated_at: at })
|
|
700
|
+
.where('state', '=', 'in_progress')
|
|
701
|
+
.execute();
|
|
702
|
+
|
|
703
|
+
// A spawn that completes here records the target its fleet row holds,
|
|
704
|
+
// as a spawn that completes as it runs does.
|
|
705
|
+
await trx
|
|
706
|
+
.updateTable('idempotency')
|
|
707
|
+
.set((eb) => ({
|
|
708
|
+
state: 'completed',
|
|
709
|
+
updated_at: at,
|
|
710
|
+
effect_target: eb
|
|
711
|
+
.selectFrom('fleet')
|
|
712
|
+
.select('fleet.target')
|
|
713
|
+
.whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
|
|
714
|
+
effect_target_identity: eb
|
|
715
|
+
.selectFrom('fleet')
|
|
716
|
+
.select('fleet.target_identity')
|
|
717
|
+
.whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
|
|
718
|
+
}))
|
|
719
|
+
.where('state', '=', 'outcome_unknown')
|
|
720
|
+
.where('operation', '=', 'session.spawn')
|
|
721
|
+
.where('effect_ref', 'in', (eb) => eb.selectFrom('fleet').select('session_id'))
|
|
722
|
+
.execute();
|
|
723
|
+
|
|
724
|
+
await trx
|
|
725
|
+
.updateTable('idempotency')
|
|
726
|
+
.set({ state: 'completed', updated_at: at })
|
|
727
|
+
.where('state', '=', 'outcome_unknown')
|
|
728
|
+
.where('operation', '=', 'session.message')
|
|
729
|
+
.where('effect_ref', 'in', (eb) => eb.selectFrom('messages').select('id'))
|
|
730
|
+
.execute();
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Only completed keys expire: a key whose outcome is unknown stays, so a
|
|
735
|
+
// retry of it keeps answering outcome_unknown instead of running anew.
|
|
736
|
+
async removeExpiredIdempotencyKeys(before: number): Promise<void> {
|
|
737
|
+
await this.db
|
|
738
|
+
.deleteFrom('idempotency')
|
|
739
|
+
.where('state', '=', 'completed')
|
|
740
|
+
.where('updated_at', '<', before)
|
|
741
|
+
.execute();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Records a materialization as it starts, in the resolving phase.
|
|
745
|
+
async createMaterialization(
|
|
746
|
+
row: Pick<
|
|
747
|
+
WorkspaceMaterialization,
|
|
748
|
+
'sessionID' | 'target' | 'dir' | 'sourceKind' | 'withheldEnv'
|
|
749
|
+
>,
|
|
750
|
+
at: number,
|
|
751
|
+
): Promise<void> {
|
|
752
|
+
await this.db
|
|
753
|
+
.insertInto('workspace_materialization')
|
|
754
|
+
.values({
|
|
755
|
+
session_id: row.sessionID,
|
|
756
|
+
target: row.target,
|
|
757
|
+
dir: row.dir,
|
|
758
|
+
source_kind: row.sourceKind,
|
|
759
|
+
phase: 'resolving',
|
|
760
|
+
repo_url: null,
|
|
761
|
+
sha: null,
|
|
762
|
+
ref: null,
|
|
763
|
+
error_code: null,
|
|
764
|
+
started_at: at,
|
|
765
|
+
updated_at: at,
|
|
766
|
+
materialized_at: null,
|
|
767
|
+
withheld_env: JSON.stringify(row.withheldEnv),
|
|
768
|
+
})
|
|
769
|
+
.execute();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async updateMaterialization(
|
|
773
|
+
sessionID: SessionID,
|
|
774
|
+
fields: MaterializationUpdate,
|
|
775
|
+
at: number,
|
|
776
|
+
): Promise<void> {
|
|
777
|
+
await this.db
|
|
778
|
+
.updateTable('workspace_materialization')
|
|
779
|
+
.set({
|
|
780
|
+
phase: fields.phase,
|
|
781
|
+
...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
|
|
782
|
+
...(fields.sha === undefined ? {} : { sha: fields.sha }),
|
|
783
|
+
...(fields.ref === undefined ? {} : { ref: fields.ref }),
|
|
784
|
+
...(fields.errorCode === undefined ? {} : { error_code: fields.errorCode }),
|
|
785
|
+
...(fields.materializedAt === undefined ? {} : { materialized_at: fields.materializedAt }),
|
|
786
|
+
updated_at: at,
|
|
787
|
+
})
|
|
788
|
+
.where('session_id', '=', sessionID)
|
|
789
|
+
.execute();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async findMaterialization(sessionID: SessionID): Promise<WorkspaceMaterialization | null> {
|
|
793
|
+
const row = await this.db
|
|
794
|
+
.selectFrom('workspace_materialization')
|
|
795
|
+
.selectAll()
|
|
796
|
+
.where('session_id', '=', sessionID)
|
|
797
|
+
.executeTakeFirst();
|
|
798
|
+
|
|
799
|
+
return row === undefined ? null : toWorkspaceMaterialization(row);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Runs once as a daemon starts, before it serves a request: a
|
|
803
|
+
// materialization still short of ready or failed belonged to a daemon that
|
|
804
|
+
// stopped partway through it, and its workspace was never verified, so it
|
|
805
|
+
// fails as interrupted.
|
|
806
|
+
async reconcileMaterializations(at: number): Promise<void> {
|
|
807
|
+
await this.db
|
|
808
|
+
.updateTable('workspace_materialization')
|
|
809
|
+
.set({ phase: 'failed', error_code: 'workspace_interrupted', updated_at: at })
|
|
810
|
+
.where('phase', 'not in', ['ready', 'failed'])
|
|
811
|
+
.execute();
|
|
812
|
+
}
|
|
813
|
+
|
|
499
814
|
async stop(): Promise<void> {
|
|
500
815
|
await this.db.destroy();
|
|
501
816
|
|
|
@@ -519,21 +834,227 @@ export class StateStore {
|
|
|
519
834
|
return;
|
|
520
835
|
}
|
|
521
836
|
|
|
522
|
-
const
|
|
837
|
+
const legacy: LegacyFleetEntry[] = [];
|
|
523
838
|
|
|
524
839
|
for (const entry of parsed) {
|
|
525
840
|
const parsedEntry = parseFleetEntry(entry);
|
|
526
841
|
|
|
527
842
|
if (parsedEntry !== undefined) {
|
|
528
|
-
|
|
843
|
+
legacy.push(parsedEntry);
|
|
529
844
|
}
|
|
530
845
|
}
|
|
531
846
|
|
|
532
|
-
await this.writeFleet(
|
|
847
|
+
await this.writeFleet(buildFleetFromLegacy(legacy));
|
|
533
848
|
} catch {}
|
|
534
849
|
}
|
|
535
850
|
}
|
|
536
851
|
|
|
852
|
+
// A fleet row's ready workspace as its entry field, or nothing for a row
|
|
853
|
+
// that ran without one.
|
|
854
|
+
function buildWorkspaceField(
|
|
855
|
+
row: Readonly<{
|
|
856
|
+
repo_url: string | null;
|
|
857
|
+
sha: string | null;
|
|
858
|
+
ref: string | null;
|
|
859
|
+
materialized_at: number | null;
|
|
860
|
+
}>,
|
|
861
|
+
): { readonly workspace?: SessionWorkspace } {
|
|
862
|
+
if (row.repo_url === null || row.sha === null || row.materialized_at === null) {
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
workspace: {
|
|
868
|
+
repoURL: row.repo_url,
|
|
869
|
+
sha: row.sha,
|
|
870
|
+
...(row.ref === null ? {} : { ref: row.ref }),
|
|
871
|
+
materializedAt: row.materialized_at,
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Two sessions share an agent session id when one resumes the other's agent
|
|
877
|
+
// session. The fleet keeps one row per agent session id, the entry written
|
|
878
|
+
// last, and relinks the survivors as a one-level hierarchy of rows it holds:
|
|
879
|
+
//
|
|
880
|
+
// 1. A link to a dropped row follows to the row that replaced it. A row that
|
|
881
|
+
// replaced its own parent follows that parent's link instead, and a link
|
|
882
|
+
// to a row the fleet does not hold makes the row top-level.
|
|
883
|
+
// 2. Where the links form a cycle, the row written first in the cycle
|
|
884
|
+
// becomes top-level.
|
|
885
|
+
// 3. A row whose parent is itself a sub-session moves up to the top-level
|
|
886
|
+
// row above it.
|
|
887
|
+
//
|
|
888
|
+
// Every walk takes at most one step per entry, so it ends on any input.
|
|
889
|
+
// A ready workspace's withheld variable names as their entry field, or
|
|
890
|
+
// nothing for a row that withholds none.
|
|
891
|
+
function buildWithheldEnvField(stored: string | null): { readonly withheldEnv?: string[] } {
|
|
892
|
+
const names = parseWithheldEnv(stored);
|
|
893
|
+
|
|
894
|
+
return names.length === 0 ? {} : { withheldEnv: names };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function parseWithheldEnv(stored: string | null): string[] {
|
|
898
|
+
const parsed: unknown = stored === null ? [] : JSON.parse(stored);
|
|
899
|
+
|
|
900
|
+
return Array.isArray(parsed)
|
|
901
|
+
? parsed.filter((name): name is string => typeof name === 'string')
|
|
902
|
+
: [];
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function buildFleetWithoutReplacedRows(entries: readonly FleetEntry[]): FleetEntry[] {
|
|
906
|
+
const survivors = new Map<AgentSessionID, SessionID>();
|
|
907
|
+
|
|
908
|
+
for (const entry of entries) {
|
|
909
|
+
if (entry.agentSessionID !== undefined) {
|
|
910
|
+
survivors.set(entry.agentSessionID, entry.sessionID);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const replaced = new Map<SessionID, SessionID>();
|
|
915
|
+
|
|
916
|
+
for (const entry of entries) {
|
|
917
|
+
const survivor =
|
|
918
|
+
entry.agentSessionID === undefined ? undefined : survivors.get(entry.agentSessionID);
|
|
919
|
+
|
|
920
|
+
if (survivor !== undefined && survivor !== entry.sessionID) {
|
|
921
|
+
replaced.set(entry.sessionID, survivor);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const kept = entries.filter((entry) => !replaced.has(entry.sessionID));
|
|
926
|
+
|
|
927
|
+
const order = new Map(kept.map((entry, index) => [entry.sessionID, index]));
|
|
928
|
+
const links = new Map(entries.map((entry) => [entry.sessionID, entry.parent]));
|
|
929
|
+
|
|
930
|
+
const bound = entries.length;
|
|
931
|
+
|
|
932
|
+
// The surviving row an entry's parent link resolves to, or undefined when
|
|
933
|
+
// it resolves to no row the fleet keeps.
|
|
934
|
+
const findSurvivingParent = (entry: FleetEntry): SessionID | undefined => {
|
|
935
|
+
let link = entry.parent;
|
|
936
|
+
|
|
937
|
+
for (let step = 0; step < bound && link !== undefined; step++) {
|
|
938
|
+
const target = replaced.get(link) ?? link;
|
|
939
|
+
|
|
940
|
+
if (target !== entry.sessionID) {
|
|
941
|
+
return order.has(target) ? target : undefined;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
link = links.get(link);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return undefined;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const linked = new Map(kept.map((entry) => [entry.sessionID, findSurvivingParent(entry)]));
|
|
951
|
+
|
|
952
|
+
// The row written first in the cycle a walk up from this row enters, or
|
|
953
|
+
// undefined when the walk reaches a top-level row.
|
|
954
|
+
const findCycleStart = (sessionID: SessionID): SessionID | undefined => {
|
|
955
|
+
const seen: SessionID[] = [];
|
|
956
|
+
let current: SessionID | undefined = sessionID;
|
|
957
|
+
|
|
958
|
+
for (let step = 0; step <= bound && current !== undefined; step++) {
|
|
959
|
+
const at = seen.indexOf(current);
|
|
960
|
+
|
|
961
|
+
if (at !== -1) {
|
|
962
|
+
return seen.slice(at).toSorted((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0))[0];
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
seen.push(current);
|
|
966
|
+
|
|
967
|
+
current = linked.get(current);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return undefined;
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
for (const entry of kept) {
|
|
974
|
+
const cycleStart = findCycleStart(entry.sessionID);
|
|
975
|
+
|
|
976
|
+
if (cycleStart !== undefined) {
|
|
977
|
+
linked.set(cycleStart, undefined);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// The top-level row a walk up from this row reaches.
|
|
982
|
+
const findTopLevel = (sessionID: SessionID): SessionID => {
|
|
983
|
+
let current = sessionID;
|
|
984
|
+
|
|
985
|
+
for (let step = 0; step < bound; step++) {
|
|
986
|
+
const parent = linked.get(current);
|
|
987
|
+
|
|
988
|
+
if (parent === undefined) {
|
|
989
|
+
return current;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
current = parent;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return current;
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const relinked: FleetEntry[] = [];
|
|
999
|
+
|
|
1000
|
+
for (const entry of kept) {
|
|
1001
|
+
const { parent: _parent, ...rest } = entry;
|
|
1002
|
+
const top = findTopLevel(entry.sessionID);
|
|
1003
|
+
const row = top === entry.sessionID ? rest : { ...rest, parent: top };
|
|
1004
|
+
|
|
1005
|
+
relinked.push(row);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return relinked;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// The ownership epoch this daemon writes under. Nothing moves a session to
|
|
1012
|
+
// a later epoch yet, so every row this daemon owns holds this one.
|
|
1013
|
+
const OWNER_EPOCH = 1;
|
|
1014
|
+
|
|
1015
|
+
// Rejects a write that touches a session another daemon owns, or one whose
|
|
1016
|
+
// stored epoch has moved past this daemon's: the write is stale.
|
|
1017
|
+
async function requireCurrentOwnership(
|
|
1018
|
+
trx: Transaction<StateStoreSchema>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely transaction bound to a live connection; not meaningfully freezable
|
|
1019
|
+
daemonID: DaemonID,
|
|
1020
|
+
sessionIDs: readonly SessionID[],
|
|
1021
|
+
): Promise<void> {
|
|
1022
|
+
if (sessionIDs.length === 0) {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const stale = await trx
|
|
1027
|
+
.selectFrom('session_owner')
|
|
1028
|
+
.select(['session_id', 'daemon_id', 'owner_epoch'])
|
|
1029
|
+
.where('session_id', 'in', sessionIDs)
|
|
1030
|
+
.where((eb) => eb.or([eb('daemon_id', '!=', daemonID), eb('owner_epoch', '!=', OWNER_EPOCH)]))
|
|
1031
|
+
.executeTakeFirst();
|
|
1032
|
+
|
|
1033
|
+
if (stale !== undefined) {
|
|
1034
|
+
throw new DaemonError(
|
|
1035
|
+
'stale_epoch',
|
|
1036
|
+
`session '${stale.session_id}' is owned by daemon '${stale.daemon_id}' at epoch ${stale.owner_epoch}; this daemon writes as '${daemonID}' at epoch ${OWNER_EPOCH}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Mints each legacy entry an atc session id, then moves each sub-session
|
|
1042
|
+
// link from the parent's agent session id to the parent's minted id.
|
|
1043
|
+
function buildFleetFromLegacy(legacy: readonly LegacyFleetEntry[]): FleetEntry[] {
|
|
1044
|
+
const minted = new Map(legacy.map((entry) => [entry.agentSessionID, toSessionID(randomUUID())]));
|
|
1045
|
+
|
|
1046
|
+
return legacy.map((entry) => {
|
|
1047
|
+
const { parent, ...rest } = entry;
|
|
1048
|
+
const parentID = parent === undefined ? undefined : minted.get(parent);
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
...rest,
|
|
1052
|
+
sessionID: minted.get(entry.agentSessionID) ?? toSessionID(randomUUID()),
|
|
1053
|
+
...(parentID === undefined ? {} : { parent: parentID }),
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
537
1058
|
function buildScopeMatch(
|
|
538
1059
|
eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
|
|
539
1060
|
scope: EventScope | null,
|
|
@@ -542,9 +1063,13 @@ function buildScopeMatch(
|
|
|
542
1063
|
return eb.lit(true);
|
|
543
1064
|
}
|
|
544
1065
|
|
|
1066
|
+
if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
|
|
1067
|
+
return eb.lit(false);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
545
1070
|
return eb.or([
|
|
546
|
-
eb('atc_id', '
|
|
547
|
-
...(scope.
|
|
1071
|
+
...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
|
|
1072
|
+
...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
|
|
548
1073
|
]);
|
|
549
1074
|
}
|
|
550
1075
|
|
|
@@ -614,6 +1139,24 @@ function buildOwnerFilter(
|
|
|
614
1139
|
: eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
|
|
615
1140
|
}
|
|
616
1141
|
|
|
1142
|
+
function toIdempotencyRecord(row: Readonly<StateStoreSchema['idempotency']>): IdempotencyRecord {
|
|
1143
|
+
return {
|
|
1144
|
+
principal: row.principal,
|
|
1145
|
+
operation: row.operation,
|
|
1146
|
+
key: row.key,
|
|
1147
|
+
payloadHash: row.payload_hash,
|
|
1148
|
+
state: row.state,
|
|
1149
|
+
effectRef: row.effect_ref,
|
|
1150
|
+
result: row.result,
|
|
1151
|
+
effectTarget:
|
|
1152
|
+
row.effect_target === null || row.effect_target_identity === null
|
|
1153
|
+
? null
|
|
1154
|
+
: { target: row.effect_target, targetIdentity: row.effect_target_identity },
|
|
1155
|
+
createdAt: row.created_at,
|
|
1156
|
+
updatedAt: row.updated_at,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
617
1160
|
function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
|
|
618
1161
|
return {
|
|
619
1162
|
id: toMessageID(row.id),
|
|
@@ -631,3 +1174,23 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
|
|
|
631
1174
|
...(row.turn_id === null ? {} : { turn: row.turn_id }),
|
|
632
1175
|
};
|
|
633
1176
|
}
|
|
1177
|
+
|
|
1178
|
+
function toWorkspaceMaterialization(
|
|
1179
|
+
row: Readonly<StateStoreSchema['workspace_materialization']>,
|
|
1180
|
+
): WorkspaceMaterialization {
|
|
1181
|
+
return {
|
|
1182
|
+
sessionID: toSessionID(row.session_id),
|
|
1183
|
+
target: row.target,
|
|
1184
|
+
dir: row.dir,
|
|
1185
|
+
sourceKind: row.source_kind,
|
|
1186
|
+
phase: row.phase,
|
|
1187
|
+
repoURL: row.repo_url,
|
|
1188
|
+
sha: row.sha,
|
|
1189
|
+
ref: row.ref,
|
|
1190
|
+
errorCode: row.error_code,
|
|
1191
|
+
startedAt: row.started_at,
|
|
1192
|
+
updatedAt: row.updated_at,
|
|
1193
|
+
materializedAt: row.materialized_at,
|
|
1194
|
+
withheldEnv: parseWithheldEnv(row.withheld_env),
|
|
1195
|
+
};
|
|
1196
|
+
}
|