mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* 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
236 lines
8.5 KiB
TypeScript
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)
|
|
}
|
|
}
|