Files
orca/src/main/runtime/agent-session-record-rows.ts
T
Brennan Benson d1aeba939f Native chat: remove unused journalCheckpoint and setConversationCommand (#30645)
* refactor(native-chat): remove three chat mechanisms nothing drives

- The adapter's optional startAnswered hook lost its only implementer in
  #27925; it was still forwarded through four layers and always read as
  answered. A start that never answered is read from the child's phase, as
  the exit already does.
- The saved lease's journalCheckpoint is only ever written as null and read
  only by validation. Records an older build saved with it still load: it
  joins the retired lease fields dropped at load, so the next write sheds it.
- The record store's setConversationCommand had no production caller. The
  tests that stood in for an older build with it now seed the saved row and
  reopen the store; three that only re-pinned another test are removed.

* test(native-chat): an older build's null journal checkpoint also loads

* Keep startAnswered cleanup in its owning PR

* Keep historical chat fixtures aligned with their readers
2026-10-20 17:12:41 -07:00

236 lines
8.5 KiB
TypeScript

// The agent-session store's rows in the host's chat journal database: every row loaded once at open,
// and exactly the rows a transaction changed written back.
//
// A record row this build cannot read is derived as unreadable at each load and never rewritten: a
// write only touches changed rows, and every mutation of an unreadable id is refused.
import {
AGENT_SESSION_RECORD_SCHEMA_VERSION,
isPersistedAgentSessionRecord,
type AgentSessionLease
} from '../../shared/agent-session-record'
import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form'
import type Database from '../sqlite/sync-database'
import type { SqliteRow } from '../sqlite/sqlite-statement'
import type { AgentSessionStoreState } from './agent-session-store-state'
import type { AgentSessionStoreRowWrites } from './agent-session-store-draft'
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import {
isReadableAgentSessionStoreOperation,
isReadableAgentSessionStoreRecord,
isReadableAgentSessionStoreTab,
isReadableRetiredAgentSessionClaimKey
} from './agent-session-store-row-rules'
import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table'
/** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first;
* no row count can say which. */
const SESSION_TABS_RECORDED = 'session_tabs_recorded'
/** Lease fields older builds wrote and nothing reads, dropped at load so no write carries them on. */
type RetiredAgentSessionLeaseFields = {
processlessAt?: unknown
settlementRetryRequired?: unknown
settlementRetryId?: unknown
journalCheckpoint?: unknown
}
export function withoutRetiredLeaseLatches(lease: AgentSessionLease): AgentSessionLease {
const {
processlessAt: _processlessAt,
settlementRetryRequired: _settlementRetryRequired,
settlementRetryId: _settlementRetryId,
journalCheckpoint: _journalCheckpoint,
...current
}: AgentSessionLease & RetiredAgentSessionLeaseFields = lease
return current
}
function text(row: SqliteRow, column: string): string | null {
const value = row[column]
return typeof value === 'string' ? value : null
}
function parseJson(json: string | null): { ok: true; value: unknown } | { ok: false } {
if (json === null) {
return { ok: false }
}
try {
return { ok: true, value: JSON.parse(json) }
} catch {
return { ok: false }
}
}
function unreadableRecordReason(value: unknown): string {
if (isPersistedAgentSessionRecord(value)) {
return 'record_key_session_id_mismatch'
}
const schemaVersion =
typeof value === 'object' && value !== null && 'schemaVersion' in value
? value.schemaVersion
: undefined
return schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION
? 'current_shape_invalid'
: 'unsupported_schema'
}
/**
* The whole store, read once. Every lease loads unreconciled: the process that wrote it may still
* be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and
* tab rows this build cannot read are skipped, as a record row it cannot read is set aside.
*/
export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionStoreState {
const state: AgentSessionStoreState = {
records: new Map(),
operations: new Map(),
retiredClaimKeys: [],
unreadableRecords: new Map(),
sessionTabs: null
}
// Only a read-only database a newer build wrote can lack them; its chats then list as none here.
if (
!db
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
.get('agent_session_records')
) {
return state
}
for (const row of db
.prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid')
.all()) {
const sessionId = text(row, 'session_id')
if (sessionId === null) {
continue
}
const parsed = parseJson(text(row, 'record_json'))
const value = parsed.ok ? parsed.value : text(row, 'record_json')
if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) {
const { record } = decodePersistedAgentSessionRecord(value)
state.records.set(sessionId, {
...record,
lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true }
})
} else {
state.unreadableRecords.set(sessionId, {
reason: unreadableRecordReason(value),
raw: value
})
}
}
for (const row of db
.prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid')
.all()) {
const key = text(row, 'operation_key')
const parsed = parseJson(text(row, 'row_json'))
if (key !== null && parsed.ok && isReadableAgentSessionStoreOperation(key, parsed.value)) {
state.operations.set(key, parsed.value)
}
}
for (const row of db
.prepare(
'SELECT key_id AS keyId, retired_at AS retiredAt FROM agent_session_retired_claim_keys ORDER BY rowid'
)
.all()) {
const entry = { keyId: row.keyId, retiredAt: Number(row.retiredAt) }
if (isReadableRetiredAgentSessionClaimKey(entry)) {
state.retiredClaimKeys.push(entry)
}
}
const recorded = db
.prepare('SELECT 1 AS present FROM agent_session_store_meta WHERE key = ?')
.get(SESSION_TABS_RECORDED)
const table = new AgentSessionTabTable()
for (const row of db
.prepare(
'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position'
)
.all()) {
const entry = { tabId: row.tabId, sessionId: row.sessionId }
if (
isReadableAgentSessionStoreTab(entry) &&
table.sessionIdFor(entry.tabId) === undefined &&
table.tabIdFor(entry.sessionId) === undefined
) {
table.show(entry.sessionId, entry.tabId)
}
}
if (recorded) {
state.sessionTabs = table
}
return state
}
function writeTabIndex(
db: Database.Database,
index: { recorded: boolean; tabs: readonly PersistedAgentSessionTab[] }
): void {
db.prepare('DELETE FROM agent_session_tabs').run()
const insert = db.prepare(
'INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)'
)
index.tabs.forEach(({ tabId, sessionId }, position) => insert.run(tabId, sessionId, position))
if (index.recorded) {
db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run(
SESSION_TABS_RECORDED,
'1'
)
} else {
db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED)
}
}
// TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts.
export function insertAgentSessionOperationRowsIfAbsent(
db: Database.Database,
rows: AgentSessionStoreRowWrites['operations']['upsert']
): void {
const insert = db.prepare(
'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO NOTHING'
)
for (const [key, json] of rows) {
if (insert.run(key, json).changes === 0) {
throw agentSessionRefusalError('agent_session_operation_unknown', {
reason: 'outcomeUnknown'
})
}
}
}
/** Exactly the rows a transaction changed. The caller holds the journal transaction. */
export function writeAgentSessionStoreRows(
db: Database.Database,
writes: AgentSessionStoreRowWrites
): void {
const upsertRecord = db.prepare(
'INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET record_json = excluded.record_json'
)
for (const [sessionId, json] of writes.records.upsert) {
upsertRecord.run(sessionId, json)
}
for (const sessionId of writes.records.remove) {
db.prepare('DELETE FROM agent_session_records WHERE session_id = ?').run(sessionId)
}
const upsertOperation = db.prepare(
'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO UPDATE SET row_json = excluded.row_json'
)
for (const [key, json] of writes.operations.upsert) {
upsertOperation.run(key, json)
}
for (const key of writes.operations.remove) {
db.prepare('DELETE FROM agent_session_operations WHERE operation_key = ?').run(key)
}
if (writes.retiredClaimKeys) {
db.prepare('DELETE FROM agent_session_retired_claim_keys').run()
const insert = db.prepare(
'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)'
)
for (const { keyId, retiredAt } of writes.retiredClaimKeys) {
insert.run(keyId, retiredAt)
}
}
if (writes.sessionTabs) {
writeTabIndex(db, writes.sessionTabs)
}
}