mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
refactor(native-chat): remove the records-file and per-chat journal imports (#26038)
* refactor(native-chat): remove the records-file and per-chat journal imports Every native chat user is on a build that already moved chat records and history into the app-wide database, so the one-time copies are dead code: the agent-sessions.json import and its owed-copy flag, the per-chat journal.db import and the write-queue hold behind it, and the pre-SQLite log.jsonl notice. * refactor(native-chat): remove what only the deleted imports used - JournalHostDatabase.unsyncedTransaction and the synchronous-pragma reset that undid it; JOURNAL_SYNCHRONOUS is now module-local. The stranded rollback test drives transaction() instead. - deleteUnpublishedJournalRows and its SQL, plus its test. - boundJournalStatusText. - Stale comments naming the removed first-use copy (queued messages, send refusal example, JOURNAL_SYNCHRONOUS doc). - Write-queue and readInOrder comments: a write also lags when issued behind one still waiting in line. - Resolved-append ordering test binds the sink from inside a running read, so a resolver that read at handover now fails it. * test(native-chat): run resolved-append in the SQLite runtime project
This commit is contained in:
-227
@@ -1,227 +0,0 @@
|
||||
// Version 4: the chat records join the journal database, copied in by the migration's own
|
||||
// transaction, so "copied" is exactly `user_version >= 4`.
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
journalPragmaNumber,
|
||||
NO_LEGACY_JOURNAL_RECORDS,
|
||||
openJournalDatabase,
|
||||
readJournalDatabaseVersion,
|
||||
type JournalLegacyRecordImport
|
||||
} from './journal-database'
|
||||
import { createJournalTablesSql } from './journal-database-schema'
|
||||
import { journalDatabasePath } from './journal-host-database'
|
||||
|
||||
let root: string
|
||||
let dbPath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-'))
|
||||
dbPath = journalDatabasePath(root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** What a build from before the records moved left: the released version-3 shape. */
|
||||
function seedVersion3(): void {
|
||||
const db = new Database(dbPath)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.exec(createJournalTablesSql())
|
||||
db.prepare(
|
||||
"INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')"
|
||||
).run()
|
||||
db.pragma('user_version = 3')
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** A copy of one record row, counting how many times the migration ran it. */
|
||||
function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } {
|
||||
const copy = {
|
||||
owed: false as const,
|
||||
runs: 0,
|
||||
write: (db: Database.Database) => {
|
||||
copy.runs += 1
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)'
|
||||
).run(sessionId, '{}')
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function inspect<T>(read: (db: Database.Database) => T): T {
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
return read(db)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
const recordIds = (db: Database.Database): string[] =>
|
||||
db
|
||||
.prepare('SELECT session_id FROM agent_session_records ORDER BY session_id')
|
||||
.all()
|
||||
.map((row) => String(row.session_id))
|
||||
|
||||
const hasTable = (db: Database.Database, name: string): boolean =>
|
||||
db
|
||||
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name) !== undefined
|
||||
|
||||
describe('the version-4 migration', () => {
|
||||
it('moves a version-3 database to 4 with its rows and the copied records', () => {
|
||||
seedVersion3()
|
||||
const copy = importOf('session-a')
|
||||
|
||||
const opened = openJournalDatabase(dbPath, copy)
|
||||
opened.db.close()
|
||||
|
||||
expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false })
|
||||
expect(copy.runs).toBe(1)
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(4)
|
||||
expect(recordIds(db)).toEqual(['session-a'])
|
||||
expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('never runs the copy again once the database is at 4', () => {
|
||||
seedVersion3()
|
||||
openJournalDatabase(dbPath, importOf('session-a')).db.close()
|
||||
const again = importOf('session-b')
|
||||
|
||||
openJournalDatabase(dbPath, again).db.close()
|
||||
|
||||
expect(again.runs).toBe(0)
|
||||
inspect((db) => expect(recordIds(db)).toEqual(['session-a']))
|
||||
})
|
||||
|
||||
it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => {
|
||||
seedVersion3()
|
||||
const original = Database.prototype.pragma
|
||||
const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function (
|
||||
this: Database.Database,
|
||||
sql: string,
|
||||
options?: { simple?: boolean }
|
||||
) {
|
||||
if (sql === 'user_version = 4') {
|
||||
throw new Error('crash before the version is published')
|
||||
}
|
||||
return original.call(this, sql, options)
|
||||
})
|
||||
|
||||
expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow(
|
||||
'crash before the version is published'
|
||||
)
|
||||
pragma.mockRestore()
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(3)
|
||||
expect(hasTable(db, 'agent_session_records')).toBe(false)
|
||||
})
|
||||
|
||||
const retry = importOf('session-a')
|
||||
openJournalDatabase(dbPath, retry).db.close()
|
||||
expect(retry.runs).toBe(1)
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(4)
|
||||
expect(recordIds(db)).toEqual(['session-a'])
|
||||
})
|
||||
})
|
||||
|
||||
it('makes the tables but keeps the copy owed when the records file could not be read', () => {
|
||||
seedVersion3()
|
||||
|
||||
const opened = openJournalDatabase(dbPath, { owed: true })
|
||||
// A chat created while the copy is owed keeps its row through the copy that follows.
|
||||
opened.db
|
||||
.prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')")
|
||||
.run()
|
||||
opened.db.close()
|
||||
|
||||
expect(opened.legacyRecordImportOwed).toBe(true)
|
||||
inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3))
|
||||
openJournalDatabase(dbPath, importOf('session-a')).db.close()
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(4)
|
||||
expect(recordIds(db)).toEqual(['new', 'session-a'])
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a fresh file with the released version while the copy is owed', () => {
|
||||
openJournalDatabase(dbPath, { owed: true }).db.close()
|
||||
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(3)
|
||||
expect(hasTable(db, 'journal_rows')).toBe(true)
|
||||
expect(hasTable(db, 'agent_session_records')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// An install's first probe must not leave an empty database that reads as a profile with chats.
|
||||
it('reads a missing database as version 0 without creating it', () => {
|
||||
expect(readJournalDatabaseVersion(dbPath)).toBe(0)
|
||||
expect(existsSync(dbPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('creates every table on a fresh file at version 4', () => {
|
||||
openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close()
|
||||
|
||||
inspect((db) => {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(4)
|
||||
for (const table of [
|
||||
'journal_rows',
|
||||
'agent_session_records',
|
||||
'agent_session_operations',
|
||||
'agent_session_retired_claim_keys',
|
||||
'agent_session_tabs',
|
||||
'agent_session_store_meta'
|
||||
]) {
|
||||
expect(hasTable(db, table)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The open of a build released before the records moved (version 3), pinned here: it latches any
|
||||
* higher version read-only. Its records file stays its own and writable, so nothing it does can
|
||||
* reach the records this database holds.
|
||||
*/
|
||||
function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } {
|
||||
const probe = new Database(path)
|
||||
const stored = journalPragmaNumber(probe, 'user_version')
|
||||
if (stored > 3) {
|
||||
probe.close()
|
||||
return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true }
|
||||
}
|
||||
return { db: probe, readOnly: false }
|
||||
}
|
||||
|
||||
describe('a build from before the move', () => {
|
||||
it('opens a version-4 database read-only, so it never writes beside the records', () => {
|
||||
openJournalDatabase(dbPath, importOf('session-a')).db.close()
|
||||
|
||||
const older = openAsVersion3Build(dbPath)
|
||||
try {
|
||||
expect(older.readOnly).toBe(true)
|
||||
expect(() =>
|
||||
older.db
|
||||
.prepare(
|
||||
"INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')"
|
||||
)
|
||||
.run()
|
||||
).toThrow(/readonly/i)
|
||||
} finally {
|
||||
older.db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,10 @@
|
||||
//
|
||||
// `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`.
|
||||
// `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so
|
||||
// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it
|
||||
// on this same schema version; this build does neither. `journal_imports`
|
||||
// records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and
|
||||
// `journal_set_aside` each chat whose per-chat file is not this build's history and is never read
|
||||
// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the
|
||||
// retired claim keys and the chat tab index (agent-session-record-rows.ts).
|
||||
// an append is one INSERT. `journal_repairs`, `journal_imports` and `journal_set_aside` are only
|
||||
// for an older build, which reads and writes them on this same schema version; this build does
|
||||
// neither. The `agent_session_*` tables hold each chat's ownership record, its operation ledger,
|
||||
// the retired claim keys and the chat tab index (agent-session-record-rows.ts).
|
||||
|
||||
/** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version
|
||||
* (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here
|
||||
@@ -51,8 +49,8 @@ CREATE TABLE IF NOT EXISTS journal_set_aside (
|
||||
`
|
||||
}
|
||||
|
||||
/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON
|
||||
* value as the file held it, so a row this build cannot read stays byte-identical. */
|
||||
/** Version 4: the chat records. Each row is one JSON value, so a row this build cannot read stays
|
||||
* byte-identical. */
|
||||
export function createAgentSessionRecordTablesSql(): string {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS agent_session_records (
|
||||
|
||||
@@ -8,15 +8,17 @@ import {
|
||||
JOURNAL_BUSY_TIMEOUT_MS,
|
||||
JOURNAL_SIZE_LIMIT_BYTES,
|
||||
journalPragmaNumber,
|
||||
NO_LEGACY_JOURNAL_RECORDS,
|
||||
openJournalDatabase
|
||||
} from './journal-database'
|
||||
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
|
||||
import {
|
||||
createJournalTablesSql,
|
||||
JOURNAL_DB_OLDEST_RELEASED_VERSION,
|
||||
JOURNAL_DB_SCHEMA_VERSION
|
||||
} from './journal-database-schema'
|
||||
import { journalDatabasePath } from './journal-host-database'
|
||||
import { JournalUnreleasedSchemaError } from './journal-open-failure'
|
||||
import {
|
||||
deleteJournalEpochRows,
|
||||
deleteUnpublishedJournalRows,
|
||||
insertJournalRow,
|
||||
iterateJournalEpochRows,
|
||||
publishJournalSessionEpoch,
|
||||
@@ -66,7 +68,7 @@ afterEach(async () => {
|
||||
|
||||
describe('the host journal database open', () => {
|
||||
it('creates every table and reads back every load-bearing pragma', () => {
|
||||
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const db = openJournalDatabase(dbPath).db
|
||||
try {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
@@ -97,14 +99,14 @@ describe('the host journal database open', () => {
|
||||
|
||||
// T5: a newer build's database opens read-only, its rows readable, and is left byte-identical.
|
||||
it('opens a newer user_version read-only without touching the file', async () => {
|
||||
const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const seeded = openJournalDatabase(dbPath).db
|
||||
publishJournalSessionEpoch(seeded, SESSION, 'epoch-1')
|
||||
insertJournalRow(seeded, 'session-1', epochRow(1))
|
||||
seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`)
|
||||
seeded.close()
|
||||
const before = await digest(dbPath)
|
||||
|
||||
const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
expect(opened.readOnly).toBe(true)
|
||||
expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1')
|
||||
@@ -121,7 +123,7 @@ describe('the host journal database open', () => {
|
||||
|
||||
it('closes the raw connection when schema setup throws', async () => {
|
||||
const failing = join(root, 'nested', 'agent-session-journal.db')
|
||||
expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow()
|
||||
expect(() => openJournalDatabase(failing)).toThrow()
|
||||
await expect(stat(`${failing}-wal`)).rejects.toThrow()
|
||||
await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined()
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-db-'))
|
||||
@@ -130,7 +132,7 @@ describe('the host journal database open', () => {
|
||||
|
||||
describe('journal row statements', () => {
|
||||
it('serves replay, resume and an epoch discard', () => {
|
||||
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const db = openJournalDatabase(dbPath).db
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
for (let seq = 1; seq <= 5; seq += 1) {
|
||||
@@ -157,27 +159,8 @@ describe('journal row statements', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes only the rows no pointer names', () => {
|
||||
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
try {
|
||||
insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying'))
|
||||
insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live'))
|
||||
insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale'))
|
||||
publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live')
|
||||
|
||||
deleteUnpublishedJournalRows(db, 'session-1')
|
||||
deleteUnpublishedJournalRows(db, 'session-2')
|
||||
|
||||
expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([])
|
||||
expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1])
|
||||
expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([])
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a duplicate sequence inside one epoch of one chat', () => {
|
||||
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const db = openJournalDatabase(dbPath).db
|
||||
try {
|
||||
insertJournalRow(db, 'session-1', epochRow(1))
|
||||
expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow()
|
||||
@@ -189,7 +172,7 @@ describe('journal row statements', () => {
|
||||
})
|
||||
|
||||
it('moves the epoch pointer in place', () => {
|
||||
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const db = openJournalDatabase(dbPath).db
|
||||
try {
|
||||
publishJournalSessionEpoch(db, SESSION, 'epoch-1')
|
||||
publishJournalSessionEpoch(db, SESSION, 'epoch-2')
|
||||
@@ -217,9 +200,7 @@ describe('schema creation', () => {
|
||||
return original.call(this, sql, options)
|
||||
})
|
||||
|
||||
expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow(
|
||||
'crash before the version is published'
|
||||
)
|
||||
expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published')
|
||||
pragma.mockRestore()
|
||||
|
||||
const inspected = new Database(dbPath)
|
||||
@@ -235,6 +216,25 @@ describe('schema creation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('gives a version 3 database the chat record tables, keeping its rows', () => {
|
||||
const released = new Database(dbPath)
|
||||
released.exec(createJournalTablesSql())
|
||||
released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')")
|
||||
released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`)
|
||||
released.close()
|
||||
|
||||
const { db } = openJournalDatabase(dbPath)
|
||||
try {
|
||||
expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION)
|
||||
expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([
|
||||
{ session_id: 's1' }
|
||||
])
|
||||
expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ n: 0 })
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Versions 1 and 2 were written only by unreleased builds; neither is migrated.
|
||||
it.each([1, 2])('refuses a version %i database without touching the file', async (version) => {
|
||||
const earlier = new Database(dbPath)
|
||||
@@ -249,12 +249,8 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`)
|
||||
earlier.close()
|
||||
const before = await digest(dbPath)
|
||||
|
||||
expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow(
|
||||
`unreleased schema ${version}`
|
||||
)
|
||||
expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow(
|
||||
JournalUnreleasedSchemaError
|
||||
)
|
||||
expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`)
|
||||
expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError)
|
||||
|
||||
expect(await digest(dbPath)).toBe(before)
|
||||
await expect(stat(`${dbPath}-wal`)).rejects.toThrow()
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// no DDL: a database written by a newer schema must be left byte-identical, and
|
||||
// `journal_mode = WAL` writes the file header.
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files'
|
||||
import {
|
||||
@@ -19,62 +18,21 @@ import { ensureQueuedMessagesTable } from './queued-message-schema'
|
||||
export const JOURNAL_BUSY_TIMEOUT_MS = 5000
|
||||
/** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */
|
||||
export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024
|
||||
/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */
|
||||
export const JOURNAL_SYNCHRONOUS = 'FULL'
|
||||
/** Every commit is fsynced before its caller continues. */
|
||||
const JOURNAL_SYNCHRONOUS = 'FULL'
|
||||
|
||||
export type OpenJournalDatabase = {
|
||||
db: Database.Database
|
||||
/** A newer `user_version` was met: this build reads and never writes. */
|
||||
readOnly: boolean
|
||||
/** The chat records file could not be read this launch: version 4's copy of it is still owed. */
|
||||
legacyRecordImportOwed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What version 4's migration copies in from the chat records file that preceded it, read before
|
||||
* the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are
|
||||
* made, and the copy and the version bump wait for a launch whose read succeeds.
|
||||
*/
|
||||
export type JournalLegacyRecordImport =
|
||||
| { owed: true }
|
||||
| { owed: false; write: (db: Database.Database) => void }
|
||||
|
||||
export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = {
|
||||
owed: false,
|
||||
write: () => undefined
|
||||
}
|
||||
|
||||
export function journalPragmaNumber(db: Database.Database, name: string): number {
|
||||
return Number(db.pragma(name, { simple: true }) ?? 0)
|
||||
}
|
||||
|
||||
/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records
|
||||
* file first. */
|
||||
export function journalDatabaseMigratesRecords(stored: number): boolean {
|
||||
return (
|
||||
stored === 0 ||
|
||||
(stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION)
|
||||
)
|
||||
}
|
||||
|
||||
/** 0 for a database not created yet: the probe never creates the file. */
|
||||
export function readJournalDatabaseVersion(dbPath: string): number {
|
||||
if (!existsSync(dbPath)) {
|
||||
return 0
|
||||
}
|
||||
const probe = new Database(dbPath)
|
||||
try {
|
||||
return journalPragmaNumber(probe, 'user_version')
|
||||
} finally {
|
||||
probe.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy
|
||||
* of the records file is still owed, so the file answers for the chats it holds.
|
||||
*/
|
||||
export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined {
|
||||
/** Whether the database holds any chat record or tab, read-only. */
|
||||
export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean {
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
const tables = new Set(
|
||||
@@ -83,23 +41,16 @@ export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | und
|
||||
.all()
|
||||
.map(({ name }) => name)
|
||||
)
|
||||
const holds = ['agent_session_records', 'agent_session_tabs'].some(
|
||||
return ['agent_session_records', 'agent_session_tabs'].some(
|
||||
(table) =>
|
||||
tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined
|
||||
)
|
||||
if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) {
|
||||
return holds
|
||||
}
|
||||
return undefined
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export function openJournalDatabase(
|
||||
dbPath: string,
|
||||
legacyRecords: JournalLegacyRecordImport
|
||||
): OpenJournalDatabase {
|
||||
export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
|
||||
const probe = new Database(dbPath)
|
||||
let stored: number
|
||||
try {
|
||||
@@ -112,8 +63,7 @@ export function openJournalDatabase(
|
||||
probe.close()
|
||||
return {
|
||||
db: new Database(dbPath, { readonly: true, fileMustExist: true }),
|
||||
readOnly: true,
|
||||
legacyRecordImportOwed: false
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
let transferred = false
|
||||
@@ -125,14 +75,14 @@ export function openJournalDatabase(
|
||||
)
|
||||
}
|
||||
configureJournalPragmas(probe, stored)
|
||||
const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords)
|
||||
migrateJournalSchema(probe, stored)
|
||||
// Outside `migrateJournalSchema` on purpose: its early return skips a db
|
||||
// already at the current version, and this table must exist at EVERY
|
||||
// writable open with no `user_version` bump (see `ensureQueuedMessagesTable`).
|
||||
ensureQueuedMessagesTable(probe)
|
||||
hardenSqliteDatabaseFiles(dbPath)
|
||||
transferred = true
|
||||
return { db: probe, readOnly: false, legacyRecordImportOwed }
|
||||
return { db: probe, readOnly: false }
|
||||
} finally {
|
||||
if (!transferred) {
|
||||
probe.close()
|
||||
@@ -159,35 +109,20 @@ function configureJournalPragmas(db: Database.Database, stored: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the
|
||||
* tables first left a shaped database still reporting version 0, which an older build does not
|
||||
* latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it.
|
||||
* Returns whether the copy is still owed.
|
||||
* Table creation and the `user_version` bump are ONE transaction: creating the tables first left a
|
||||
* shaped database still reporting version 0, which an older build does not latch read-only.
|
||||
*/
|
||||
function migrateJournalSchema(
|
||||
db: Database.Database,
|
||||
stored: number,
|
||||
legacyRecords: JournalLegacyRecordImport
|
||||
): boolean {
|
||||
function migrateJournalSchema(db: Database.Database, stored: number): void {
|
||||
if (stored >= JOURNAL_DB_SCHEMA_VERSION) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
runJournalTransaction(db, () => {
|
||||
if (stored === 0) {
|
||||
db.exec(createJournalTablesSql())
|
||||
}
|
||||
db.exec(createAgentSessionRecordTablesSql())
|
||||
if (legacyRecords.owed) {
|
||||
// A fresh file still takes a released version, so an older build opens it as one.
|
||||
if (stored === 0) {
|
||||
db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
legacyRecords.write(db)
|
||||
db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`)
|
||||
})
|
||||
return legacyRecords.owed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
// An empty chat beside a pre-SQLite journal explains itself.
|
||||
//
|
||||
// The SQLite move shipped no importer, so a session whose history is a
|
||||
// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a
|
||||
// chat created seconds ago. One status row is the difference.
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection'
|
||||
import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant'
|
||||
import { journalDirectoryFor } from './journal-paths'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import type { openAgentSessionJournal } from './journal-store-factory'
|
||||
import {
|
||||
createTrackedJournalOpener,
|
||||
openTestJournalHostDatabase,
|
||||
loadTestJournal,
|
||||
deleteTestJournalRow,
|
||||
insertTestJournalRowJson,
|
||||
liveTestJournalRows,
|
||||
SAVED_BY_NEWER_ORCA
|
||||
} from './journal-host-database-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
|
||||
const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY)
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: root,
|
||||
now: () => (clock += 1),
|
||||
mintEpoch: () => `epoch-${clock}`,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
/** Where this chat's history lived before the journal was one database per host. */
|
||||
function legacyDir(): string {
|
||||
return journalDirectoryFor(root, IDENTITY)
|
||||
}
|
||||
|
||||
async function writeRemnant(name = 'log.jsonl'): Promise<void> {
|
||||
await mkdir(legacyDir(), { recursive: true })
|
||||
await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8')
|
||||
}
|
||||
|
||||
function disclosure(journal: AgentSessionJournal): string | null {
|
||||
const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID)
|
||||
return row?.body.kind === 'status' ? row.body.text : null
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('a chat whose history is still in the pre-SQLite format', () => {
|
||||
it('says how to carry on, and where the transcript is', async () => {
|
||||
await writeRemnant()
|
||||
|
||||
const journal = await open()
|
||||
|
||||
expect(disclosure(journal)).toContain('send a message to pick up where you left off')
|
||||
expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl'))
|
||||
expect(disclosure(journal)).toContain('Codex')
|
||||
})
|
||||
|
||||
// Both files is the normal shape of a pre-SQLite directory: every epoch roll
|
||||
// staged a snapshot whether or not anything compacted into it, so preferring
|
||||
// the snapshot would name an empty file for ~every affected chat.
|
||||
it('names the log, not the snapshot staged beside it', async () => {
|
||||
await writeRemnant('log.jsonl')
|
||||
await writeRemnant('snapshot.json')
|
||||
|
||||
const journal = await open()
|
||||
|
||||
expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl'))
|
||||
expect(disclosure(journal)).not.toContain('snapshot.json')
|
||||
})
|
||||
|
||||
it('falls back to the snapshot when a chat has no log beside it', async () => {
|
||||
await writeRemnant('snapshot.json')
|
||||
|
||||
const journal = await open()
|
||||
|
||||
expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json'))
|
||||
})
|
||||
|
||||
it('says nothing to a chat that is genuinely new', async () => {
|
||||
const journal = await open()
|
||||
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
})
|
||||
|
||||
// Counting rows proves nothing here — the append upserts by identity, so a
|
||||
// second append would still leave exactly one. The revision is what moves.
|
||||
it('does not re-append the row on a later open', async () => {
|
||||
await writeRemnant()
|
||||
const first = await open()
|
||||
const firstRevision = first
|
||||
.snapshot()
|
||||
.items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision
|
||||
await first.close()
|
||||
|
||||
const reopened = await open()
|
||||
|
||||
const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)
|
||||
expect(firstRevision).toBe(1)
|
||||
expect(row?.revision).toBe(1)
|
||||
expect(reopened.cursor().sequence).toBe(2)
|
||||
})
|
||||
|
||||
// The epoch commit and this append are separate transactions; if the append is
|
||||
// lost the epoch exists but holds nothing, and every later open takes the
|
||||
// adopt branch. The offer has to survive that.
|
||||
it('offers the message again when a committed epoch holds nothing', async () => {
|
||||
const founded = await open()
|
||||
await founded.close()
|
||||
await writeRemnant()
|
||||
|
||||
const reopened = await open()
|
||||
|
||||
expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl'))
|
||||
})
|
||||
|
||||
// A damaged journal fails its open before this branch, so nothing is appended to it.
|
||||
it('writes nothing into a damaged journal', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.close()
|
||||
const opened = openTestJournalHostDatabase(root)
|
||||
try {
|
||||
deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1)
|
||||
} finally {
|
||||
opened.close()
|
||||
}
|
||||
await writeRemnant()
|
||||
|
||||
await expect(open()).rejects.toMatchObject({
|
||||
refusal: { details: { reason: 'journalCorrupt' } }
|
||||
})
|
||||
expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
|
||||
damage: { sequence: 1, cause: 'no-epoch-row' }
|
||||
})
|
||||
const stored = openTestJournalHostDatabase(root)
|
||||
try {
|
||||
expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2])
|
||||
} finally {
|
||||
stored.close()
|
||||
}
|
||||
})
|
||||
|
||||
// A newer row fails the load before the empty-epoch branch would write a notice.
|
||||
it("writes nothing into a newer Orca's journal, whose open is refused", async () => {
|
||||
const founded = await open()
|
||||
const epoch = founded.epoch
|
||||
await founded.close()
|
||||
insertTestJournalRowJson(
|
||||
openTestJournalHostDatabase(root).db,
|
||||
IDENTITY.sessionId,
|
||||
2,
|
||||
JSON.stringify({
|
||||
v: 99,
|
||||
kind: 'item',
|
||||
epoch,
|
||||
seq: 2,
|
||||
fence: 1,
|
||||
ts: 1,
|
||||
itemId: 'future',
|
||||
revision: 1,
|
||||
body: { kind: 'status', text: 'from a newer build' }
|
||||
})
|
||||
)
|
||||
await writeRemnant()
|
||||
|
||||
const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)
|
||||
|
||||
await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
|
||||
expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual(
|
||||
before
|
||||
)
|
||||
})
|
||||
|
||||
// A row nothing projects is a row nobody reads.
|
||||
it('renders in the transcript as a system line', async () => {
|
||||
await writeRemnant()
|
||||
|
||||
const journal = await open()
|
||||
|
||||
const messages = projectStructuredItemsToNativeChat(journal.snapshot().items)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.role).toBe('system')
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
// A journal directory left behind by the pre-SQLite file format.
|
||||
//
|
||||
// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript.
|
||||
// This is Orca's own `log.jsonl`, which no build after the SQLite move reads.
|
||||
// Nothing imports it, so the session it belonged to opens empty and is
|
||||
// indistinguishable from a chat created seconds ago — same `session_created`
|
||||
// epoch, same empty timeline. The remnant is the one durable fact that tells
|
||||
// them apart, so the empty session says where its history went and how to carry
|
||||
// on instead of silently claiming it never had any.
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import { boundJournalStatusText } from './journal-prompt-body-bounds'
|
||||
import { formatAgentTypeLabel } from '../../../shared/agent-type-label'
|
||||
import type { AgentType } from '../../../shared/agent-status-types'
|
||||
|
||||
/** The remnant's transcript, or null when the directory never held one.
|
||||
*
|
||||
* `log.jsonl` first, and the order matters: every epoch roll staged a
|
||||
* `snapshot.json` whether or not anything was ever compacted into it, so the
|
||||
* file's existence says nothing about where the history lives. Measured across
|
||||
* a real profile, `compactedThrough` was 0 in all 80 — the log holds the
|
||||
* transcript and the snapshot is the fallback for a session that has no log. */
|
||||
export function findJournalFileFormatRemnant(journalDir: string): string | null {
|
||||
for (const name of ['log.jsonl', 'snapshot.json']) {
|
||||
const path = join(journalDir, name)
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** One stable identity, so a reopen upserts the same row instead of adding one. */
|
||||
export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
|
||||
provider: 'orca',
|
||||
clientMessageId: 'journal-file-format-remnant'
|
||||
}
|
||||
|
||||
/** How to carry on. The session attaches on the record's own provider handle, so it
|
||||
* still names the conversation the transcript no longer shows — whether the provider
|
||||
* itself still holds that thread is its own business, hence "points at". */
|
||||
export function journalFileFormatRemnantDisclosure(input: {
|
||||
transcriptPath: string
|
||||
agent: AgentType
|
||||
}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } {
|
||||
return {
|
||||
identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: boundJournalStatusText(
|
||||
`This chat's history was saved in an older format Orca no longer reads, so it starts ` +
|
||||
`empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` +
|
||||
`conversation — send a message to pick up where you left off. The original ` +
|
||||
`transcript is on the session's host at \`${input.transcriptPath}\``
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
// same directory reads the same database the way a restarted host would.
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database'
|
||||
import { JournalHostDatabase } from './journal-host-database'
|
||||
import { replayJournal, type JournalLoad } from './journal-open'
|
||||
import { serializeJournalRow, type JournalRow } from './journal-row-schema'
|
||||
@@ -26,7 +25,7 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost
|
||||
if (existing && !existing.isClosed) {
|
||||
return existing
|
||||
}
|
||||
const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS)
|
||||
const database = JournalHostDatabase.open(directory)
|
||||
opened.set(directory, database)
|
||||
return database
|
||||
}
|
||||
|
||||
@@ -184,14 +184,14 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`,
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const stopFailing = failCommits(connection)
|
||||
|
||||
// A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error.
|
||||
// Its caller gets the COMMIT's own error.
|
||||
expect(() =>
|
||||
database.unsyncedTransaction((db) =>
|
||||
database.transaction((db) =>
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run('copying', 'epoch-copying', 1, 1, '{}')
|
||||
.run('unknown', 'epoch-unknown', 1, 1, '{}')
|
||||
)
|
||||
).toThrow('FOREIGN KEY constraint failed')
|
||||
expect(connection.isTransaction).toBe(true)
|
||||
@@ -210,8 +210,6 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`,
|
||||
rollbackFails = false
|
||||
expect(database.db.isTransaction).toBe(false)
|
||||
stopFailing()
|
||||
// Restored with the ROLLBACK: no later commit runs at the copy's unsynced level.
|
||||
expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2)
|
||||
await expect(
|
||||
other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
).resolves.toBeDefined()
|
||||
|
||||
@@ -6,20 +6,13 @@
|
||||
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
JOURNAL_SYNCHRONOUS,
|
||||
journalDatabaseMigratesRecords,
|
||||
NO_LEGACY_JOURNAL_RECORDS,
|
||||
openJournalDatabase,
|
||||
readJournalDatabaseVersion,
|
||||
runJournalTransaction,
|
||||
type JournalLegacyRecordImport,
|
||||
type OpenJournalDatabase
|
||||
} from './journal-database'
|
||||
import { journalOpenRefusalError } from './journal-open-failure'
|
||||
import { journalDirectoryFor } from './journal-paths'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
|
||||
const JOURNAL_DATABASE_FILE = 'agent-session-journal.db'
|
||||
@@ -32,45 +25,17 @@ export class JournalHostDatabase {
|
||||
private connection: Database.Database | null
|
||||
/** A newer Orca wrote the database: every chat's history reads, and no chat writes. */
|
||||
readonly readOnly: boolean
|
||||
/** The chat records file could not be read this launch, so its copy waits for a later one. */
|
||||
readonly legacyRecordImportOwed: boolean
|
||||
/** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */
|
||||
private stranded = false
|
||||
|
||||
private constructor(
|
||||
readonly stateDirectory: string,
|
||||
opened: OpenJournalDatabase
|
||||
) {
|
||||
private constructor(opened: OpenJournalDatabase) {
|
||||
this.connection = opened.db
|
||||
this.readOnly = opened.readOnly
|
||||
this.legacyRecordImportOwed = opened.legacyRecordImportOwed
|
||||
}
|
||||
|
||||
/** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */
|
||||
static async open(
|
||||
stateDirectory: string,
|
||||
readLegacyRecords: () => Promise<JournalLegacyRecordImport>
|
||||
): Promise<JournalHostDatabase> {
|
||||
static open(stateDirectory: string): JournalHostDatabase {
|
||||
mkdirSync(stateDirectory, { recursive: true })
|
||||
const migrates = journalDatabaseMigratesRecords(
|
||||
readJournalDatabaseVersion(journalDatabasePath(stateDirectory))
|
||||
)
|
||||
return JournalHostDatabase.openWith(
|
||||
stateDirectory,
|
||||
migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS
|
||||
)
|
||||
}
|
||||
|
||||
/** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */
|
||||
static openWith(
|
||||
stateDirectory: string,
|
||||
legacyRecords: JournalLegacyRecordImport
|
||||
): JournalHostDatabase {
|
||||
mkdirSync(stateDirectory, { recursive: true })
|
||||
return new JournalHostDatabase(
|
||||
stateDirectory,
|
||||
openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords)
|
||||
)
|
||||
return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory)))
|
||||
}
|
||||
|
||||
get isClosed(): boolean {
|
||||
@@ -95,31 +60,6 @@ export class JournalHostDatabase {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The same transaction, committed without an fsync: for rows no reader follows until a later
|
||||
* synced commit, which under WAL makes every earlier frame durable too. The setting is restored
|
||||
* in the same task, so no other chat's commit runs under it.
|
||||
*/
|
||||
unsyncedTransaction<T>(run: (db: Database.Database) => T): T {
|
||||
const db = this.db
|
||||
db.pragma('synchronous = NORMAL')
|
||||
try {
|
||||
return this.transaction(run)
|
||||
} finally {
|
||||
// SQLite refuses the change inside a transaction; freeing a stranded one restores it.
|
||||
if (!db.isTransaction) {
|
||||
db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where this chat's history lived before the journal was one database per host. */
|
||||
legacyDirectoryFor(
|
||||
identity: Pick<AgentSessionJournalIdentity, 'workspaceId' | 'sessionId'>
|
||||
): string {
|
||||
return journalDirectoryFor(this.stateDirectory, identity)
|
||||
}
|
||||
|
||||
/** Last, after every store has drained. A close that fails keeps the handle, so the retried
|
||||
* teardown closes this same connection. */
|
||||
close(): void {
|
||||
@@ -140,7 +80,6 @@ export class JournalHostDatabase {
|
||||
throw journalOpenRefusalError(error)
|
||||
}
|
||||
}
|
||||
connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`)
|
||||
this.stranded = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
|
||||
import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database'
|
||||
import { openJournalDatabase } from './journal-database'
|
||||
import {
|
||||
classifyJournalOpenFailure,
|
||||
createJournalOpenReadRefusals,
|
||||
@@ -31,7 +31,7 @@ afterEach(async () => {
|
||||
/** What the journal's own open, then a chat's replay, throws for the file as it stands. */
|
||||
function openFailure(): unknown {
|
||||
try {
|
||||
const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const db = openJournalDatabase(journalDatabasePath(root)).db
|
||||
try {
|
||||
replayJournal(db, 'session-1')
|
||||
} finally {
|
||||
@@ -62,7 +62,7 @@ describe('classifyJournalOpenFailure', () => {
|
||||
|
||||
it('calls a journal whose pages are damaged corrupt', async () => {
|
||||
const path = journalDatabasePath(root)
|
||||
const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db
|
||||
const opened = openJournalDatabase(path).db
|
||||
opened.exec('PRAGMA journal_mode = DELETE')
|
||||
opened.close()
|
||||
const bytes = await readFile(path)
|
||||
|
||||
@@ -20,11 +20,6 @@ type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unr
|
||||
/** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */
|
||||
export type JournalOpenFailure = Exclude<JournalRefusalReason, 'journalWrittenByNewerOrca'>
|
||||
|
||||
/** A per-chat file whose copy did not read back as the file: the history is not usable here. */
|
||||
export class JournalImportMismatchError extends Error {
|
||||
override readonly name = 'JournalImportMismatchError'
|
||||
}
|
||||
|
||||
/** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */
|
||||
export class JournalDamageError extends Error {
|
||||
override readonly name = 'JournalDamageError'
|
||||
@@ -59,14 +54,13 @@ export class JournalUnreleasedSchemaError extends Error {
|
||||
// Bounds a cause chain that loops back on itself.
|
||||
const MAX_CAUSE_DEPTH = 8
|
||||
|
||||
/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify,
|
||||
* or a file only an unreleased build wrote. Anything unproven can clear. */
|
||||
/** Unusable only where proven: damage the storage or the rows show, or a file only an unreleased
|
||||
* build wrote. Anything unproven can clear. */
|
||||
export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure {
|
||||
let current = error
|
||||
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) {
|
||||
if (
|
||||
isSqliteCorruption(current) ||
|
||||
current instanceof JournalImportMismatchError ||
|
||||
current instanceof JournalDamageError ||
|
||||
current instanceof JournalUnreleasedSchemaError
|
||||
) {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// A chat still in the per-chat file an earlier build left, opened the way startup restore opens
|
||||
// one: its copy into the host's database is owed, and the chat's first write pays it before that
|
||||
// write lands. It is the one backlog a chat's write queue really holds.
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
openTestJournalHostDatabase,
|
||||
readTestJournalRows
|
||||
} from './journal-host-database-test-support'
|
||||
import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths'
|
||||
import type { JournalStoredRow } from './journal-row-table'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
|
||||
const OWED_IMPORT_HISTORY_TEXT = 'history from the earlier build'
|
||||
|
||||
/** Opens `identity`'s chat from a per-chat file of real history rows, its copy still owed. */
|
||||
export async function openJournalOwingImport(input: {
|
||||
stateDirectory: string
|
||||
identity: AgentSessionJournalIdentity
|
||||
now?: () => number
|
||||
}): Promise<{ journal: AgentSessionJournal; history: JournalStoredRow[] }> {
|
||||
const { stateDirectory, identity } = input
|
||||
const history = await historyRows(input)
|
||||
await writePerChatFile(journalDirectoryFor(stateDirectory, identity), identity, history)
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity,
|
||||
database: openTestJournalHostDatabase(stateDirectory),
|
||||
...(input.now ? { now: input.now } : {}),
|
||||
deferPerSessionImport: true
|
||||
})
|
||||
if (!journal.importPending) {
|
||||
throw new Error('the chat opened with its copy already made')
|
||||
}
|
||||
return { journal, history }
|
||||
}
|
||||
|
||||
/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */
|
||||
async function historyRows(input: {
|
||||
stateDirectory: string
|
||||
identity: AgentSessionJournalIdentity
|
||||
now?: () => number
|
||||
}): Promise<JournalStoredRow[]> {
|
||||
const scratch = join(input.stateDirectory, `scratch-${input.identity.sessionId}`)
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: input.identity,
|
||||
database: openTestJournalHostDatabase(scratch),
|
||||
...(input.now ? { now: input.now } : {})
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'client-history',
|
||||
payloadFingerprint: 'fp-history',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] },
|
||||
fence: 1,
|
||||
handoverRecorded: true
|
||||
})
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-history', turnId: 'turn-history', ordinal: 1 },
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: OWED_IMPORT_HISTORY_TEXT }]
|
||||
},
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.close()
|
||||
return readTestJournalRows(
|
||||
openTestJournalHostDatabase(scratch).db,
|
||||
input.identity.sessionId,
|
||||
journal.epoch
|
||||
)
|
||||
}
|
||||
|
||||
/** The per-chat file an earlier build left, in its own schema. */
|
||||
async function writePerChatFile(
|
||||
directory: string,
|
||||
identity: AgentSessionJournalIdentity,
|
||||
rows: readonly JournalStoredRow[]
|
||||
): Promise<void> {
|
||||
const path = legacyJournalDatabaseFile(directory)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const db = new Database(path)
|
||||
try {
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.exec(`
|
||||
CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq));
|
||||
CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL,
|
||||
content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`)
|
||||
db.pragma('user_version = 2')
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
for (const row of rows) {
|
||||
insert.run(identity.sessionId, row.epoch, row.seq, row.ts, row.rowJson)
|
||||
}
|
||||
db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(
|
||||
identity.sessionId,
|
||||
rows[0]!.epoch,
|
||||
1
|
||||
)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Where a chat's history lived when the journal was one database per chat.
|
||||
//
|
||||
// Nothing writes here any more: the host's one database replaced it. The importer copies a file it
|
||||
// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format
|
||||
// remnant check looks here too.
|
||||
// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
const JOURNAL_DIR_NAME = 'agent-session-journal'
|
||||
|
||||
/** Filesystem-safe, collision-resistant segment for an arbitrary id. */
|
||||
export function journalPathSegment(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
/** `<root>/agent-session-journal/<workspace>/<session>`. */
|
||||
export function journalDirectoryFor(
|
||||
root: string,
|
||||
identity: Pick<AgentSessionJournalIdentity, 'workspaceId' | 'sessionId'>
|
||||
): string {
|
||||
return join(
|
||||
root,
|
||||
JOURNAL_DIR_NAME,
|
||||
journalPathSegment(identity.workspaceId),
|
||||
journalPathSegment(identity.sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db'
|
||||
|
||||
/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */
|
||||
export function legacyJournalDatabaseFile(journalDir: string): string {
|
||||
return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE)
|
||||
}
|
||||
@@ -1,835 +0,0 @@
|
||||
// A chat's per-chat journal file from an earlier build is copied into the host's one database on
|
||||
// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that
|
||||
// reappears after a downgrade is set aside on disk, and the chat keeps this build's history.
|
||||
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
AGENT_SESSION_JOURNAL_SCHEMA_VERSION
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
|
||||
import type { SqliteRow } from '../../sqlite/sqlite-statement'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
createTrackedJournalOpener,
|
||||
liveTestJournalRows,
|
||||
loadTestJournal,
|
||||
openTestJournalHostDatabase,
|
||||
readTestJournalRows,
|
||||
SAVED_BY_NEWER_ORCA
|
||||
} from './journal-host-database-test-support'
|
||||
import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths'
|
||||
import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
|
||||
import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table'
|
||||
import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return { ...actual, rmSync: vi.fn(actual.rmSync) }
|
||||
})
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-legacy',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function legacyDir(): string {
|
||||
return journalDirectoryFor(root, IDENTITY)
|
||||
}
|
||||
|
||||
/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */
|
||||
async function historyRows(
|
||||
epoch = 'epoch-from-the-earlier-build',
|
||||
reply = 'On it.'
|
||||
): Promise<{ epoch: string; rows: JournalStoredRow[] }> {
|
||||
const scratch = join(root, `scratch-${epoch}`)
|
||||
const journal = await journals.open({
|
||||
identity: IDENTITY,
|
||||
stateDirectory: scratch,
|
||||
now: () => (clock += 1),
|
||||
mintEpoch: () => epoch
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'client-1',
|
||||
payloadFingerprint: 'fp-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] },
|
||||
fence: 1,
|
||||
handoverRecorded: true
|
||||
})
|
||||
await journal.appendItem(
|
||||
item(1),
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
const rows = readTestJournalRows(
|
||||
openTestJournalHostDatabase(scratch).db,
|
||||
IDENTITY.sessionId,
|
||||
journal.epoch
|
||||
)
|
||||
return { epoch: journal.epoch, rows }
|
||||
}
|
||||
|
||||
/** The per-chat file an earlier build left, in its own schema. */
|
||||
async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise<void> {
|
||||
const path = legacyJournalDatabaseFile(legacyDir())
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const db = new Database(path)
|
||||
try {
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.exec(`
|
||||
CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq));
|
||||
CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL,
|
||||
content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`)
|
||||
db.pragma('user_version = 2')
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
for (const row of rows) {
|
||||
insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson)
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */
|
||||
async function leftovers(): Promise<string[]> {
|
||||
const parent = dirname(legacyDir())
|
||||
const name = legacyDir().slice(parent.length + 1)
|
||||
return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : []
|
||||
}
|
||||
|
||||
function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string {
|
||||
return JSON.stringify(journal.snapshot().items.map((entry) => entry.body))
|
||||
}
|
||||
|
||||
function rowCount(db: Database.Database): number {
|
||||
return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total)
|
||||
}
|
||||
|
||||
function removeFails(): void {
|
||||
vi.mocked(rmSync).mockImplementation(() => {
|
||||
throw Object.assign(new Error('resource busy'), { code: 'EBUSY' })
|
||||
})
|
||||
}
|
||||
|
||||
async function removeWorks(): Promise<void> {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
vi.mocked(rmSync).mockImplementation(actual.rmSync)
|
||||
}
|
||||
|
||||
/** The file as it is, except that the copy's first read of rows comes back through `alter`. */
|
||||
function alteringFirstCopiedRead(
|
||||
alter: (rows: SqliteRow[]) => SqliteRow[]
|
||||
): (path: string) => Database.Database {
|
||||
return (path) => {
|
||||
const source = new Database(path, { readonly: true, fileMustExist: true })
|
||||
const prepare = source.prepare.bind(source)
|
||||
let altered = false
|
||||
source.prepare = (sql: string) => {
|
||||
const statement = prepare(sql)
|
||||
if (altered || !sql.includes('seq > ?')) {
|
||||
return statement
|
||||
}
|
||||
const all = statement.all.bind(statement)
|
||||
// Why: prepare caches statements, so this one is handed out again after it was altered.
|
||||
statement.all = (...args: Parameters<typeof all>) => {
|
||||
const rows = all(...args)
|
||||
if (altered) {
|
||||
return rows
|
||||
}
|
||||
altered = true
|
||||
return alter(rows)
|
||||
}
|
||||
return statement
|
||||
}
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1))
|
||||
|
||||
/** Every row still there and still parsing, but the reply's words changed. */
|
||||
const garblingCopiedReply = alteringFirstCopiedRead((rows) =>
|
||||
rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') }))
|
||||
)
|
||||
|
||||
function openChat() {
|
||||
return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) })
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await removeWorks()
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('importing a per-chat journal', () => {
|
||||
it('copies the history verbatim on first open and deletes the file', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
|
||||
const journal = await openChat()
|
||||
|
||||
expect(journal.epoch).toBe(epoch)
|
||||
expect(journal.cursor()).toEqual({ epoch, sequence: rows.length })
|
||||
expect(
|
||||
readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch)
|
||||
).toEqual(rows)
|
||||
// Verified, then deleted with its WAL files: no copy of it is kept.
|
||||
expect(await leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
// T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered
|
||||
// rows would silently withdraw every "resume after update" offer.
|
||||
it('keeps a restart offer taken before the upgrade', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor.
|
||||
const marker = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
journalCursor: { epoch, sequence: rows.length }
|
||||
} as AgentSessionResumeMarker
|
||||
|
||||
const journal = await openChat()
|
||||
const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]),
|
||||
now: () => clock,
|
||||
enqueue: (operation) => operation()
|
||||
})
|
||||
|
||||
expect(withdrawal.movedOn(marker)).toBe(false)
|
||||
})
|
||||
|
||||
it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const turns: { published: boolean; copied: number }[] = []
|
||||
const tick = (): void => {
|
||||
turns.push({
|
||||
published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null,
|
||||
copied: rowCount(database.db)
|
||||
})
|
||||
pending = setImmediate(tick)
|
||||
}
|
||||
let pending = setImmediate(tick)
|
||||
|
||||
try {
|
||||
await importPerSessionJournal({
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
batchRows: 1
|
||||
})
|
||||
} finally {
|
||||
clearImmediate(pending)
|
||||
}
|
||||
|
||||
// Other work ran while rows were copied, and none of it could see a partly copied chat.
|
||||
expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0)
|
||||
expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true)
|
||||
expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows)
|
||||
})
|
||||
|
||||
// A quit between two batches: no reader sees the rows copied so far, and the next open copies the
|
||||
// chat again from the start, with no duplicate or leftover row.
|
||||
it('copies a chat again cleanly after a copy stopped midway', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
setImmediate(() => closeTestJournalHostDatabases())
|
||||
|
||||
await expect(
|
||||
importPerSessionJournal({
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
batchRows: 1
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_closed' })
|
||||
const reopened = openTestJournalHostDatabase(root)
|
||||
expect(rowCount(reopened.db)).toBe(1)
|
||||
expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull()
|
||||
expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull()
|
||||
|
||||
const other = await journals.open({
|
||||
identity: { ...IDENTITY, sessionId: 'session-other' },
|
||||
stateDirectory: root
|
||||
})
|
||||
await other.appendItem(
|
||||
item(1),
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
const journal = await openChat()
|
||||
|
||||
expect(journal.cursor()).toEqual({ epoch, sequence: rows.length })
|
||||
expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows)
|
||||
expect(rowCount(reopened.db)).toBe(
|
||||
rows.length + liveTestJournalRows(reopened.db, 'session-other').length
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a chat once when two opens of it import at the same time', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 }
|
||||
|
||||
const outcomes = await Promise.all([
|
||||
importPerSessionJournal(input),
|
||||
importPerSessionJournal(input)
|
||||
])
|
||||
|
||||
expect(outcomes).toEqual(['imported', 'absent'])
|
||||
expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows)
|
||||
expect(rowCount(database.db)).toBe(rows.length)
|
||||
})
|
||||
|
||||
// Only the copy's own batches skip the fsync: a live chat's write between them, and the publish
|
||||
// that makes the batches durable, commit fully synced.
|
||||
it('commits copy batches unsynced, and every other commit synced', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const synchronous = () => Number(database.db.pragma('synchronous', { simple: true }))
|
||||
const transaction = database.transaction.bind(database)
|
||||
const commits: number[] = []
|
||||
vi.spyOn(database, 'transaction').mockImplementation((run) =>
|
||||
transaction((db) => {
|
||||
commits.push(synchronous())
|
||||
return run(db)
|
||||
})
|
||||
)
|
||||
const between: number[] = []
|
||||
const tick = (): void => {
|
||||
// What a live chat's append would commit under, between two copy batches.
|
||||
between.push(synchronous())
|
||||
pending = setImmediate(tick)
|
||||
}
|
||||
|
||||
let pending = setImmediate(tick)
|
||||
await importPerSessionJournal({
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
batchRows: 1
|
||||
})
|
||||
// A flag would still let the queued tick read the database after teardown closes it.
|
||||
clearImmediate(pending)
|
||||
|
||||
// 2 is FULL, 1 is NORMAL.
|
||||
expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1))
|
||||
expect(commits.at(-1)).toBe(2)
|
||||
expect(between.length).toBeGreaterThan(0)
|
||||
expect(between.every((value) => value === 2)).toBe(true)
|
||||
expect(synchronous()).toBe(2)
|
||||
})
|
||||
|
||||
it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
let turns = 0
|
||||
const tick = (): void => {
|
||||
turns += 1
|
||||
}
|
||||
|
||||
setImmediate(tick)
|
||||
await importPerSessionJournal({
|
||||
database: openTestJournalHostDatabase(root),
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir()
|
||||
})
|
||||
|
||||
// One batch copies, and both verify reads are one batch each: only the final yield turns.
|
||||
expect(turns).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the file and refuses the chat when the copy does not read back as the file', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const input = {
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
openSource: losingLastCopiedRow
|
||||
}
|
||||
const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(importPerSessionJournal(input)).rejects.toMatchObject({
|
||||
refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } }
|
||||
})
|
||||
}
|
||||
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
|
||||
expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
|
||||
expect(
|
||||
database.db.prepare('SELECT count(*) AS total FROM journal_imports').get()
|
||||
).toMatchObject({
|
||||
total: 0
|
||||
})
|
||||
expect(errors).toHaveBeenCalledOnce()
|
||||
// A copy that reads back whole then imports it, over what the refused ones left.
|
||||
const journal = await openChat()
|
||||
expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows)
|
||||
expect(journal.cursor()).toEqual({ epoch, sequence: rows.length })
|
||||
expect(rowCount(database.db)).toBe(rows.length)
|
||||
})
|
||||
|
||||
it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => {
|
||||
const { epoch } = await historyRows()
|
||||
await writeLegacyJournal(epoch, [])
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const db = new Database(legacyJournalDatabaseFile(legacyDir()))
|
||||
db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1)
|
||||
db.close()
|
||||
|
||||
await expect(
|
||||
previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() })
|
||||
).resolves.toBeNull()
|
||||
const journal = await openChat()
|
||||
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
expect(journal.cursor().sequence).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
// A gap: the reply's row is gone from the file.
|
||||
await writeLegacyJournal(
|
||||
epoch,
|
||||
rows.filter((row) => row.seq !== 2)
|
||||
)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
const unloadable = {
|
||||
refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } }
|
||||
}
|
||||
const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() }
|
||||
|
||||
await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable)
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable)
|
||||
}
|
||||
await expect(openChat()).rejects.toMatchObject(unloadable)
|
||||
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
|
||||
expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
|
||||
expect(rowCount(database.db)).toBe(0)
|
||||
expect(
|
||||
database.db.prepare('SELECT count(*) AS total FROM journal_imports').get()
|
||||
).toMatchObject({ total: 0 })
|
||||
})
|
||||
|
||||
it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
const last = rows.at(-1)!
|
||||
const newer = {
|
||||
...last,
|
||||
rowJson: JSON.stringify({
|
||||
...JSON.parse(last.rowJson),
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1
|
||||
})
|
||||
}
|
||||
await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer])
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() }
|
||||
|
||||
await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
|
||||
await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
|
||||
await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
|
||||
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
|
||||
expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
|
||||
expect(rowCount(database.db)).toBe(0)
|
||||
})
|
||||
|
||||
// A copy that keeps every count but not every byte is no copy: the file is all there is.
|
||||
it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1)
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const input = {
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
openSource: garblingCopiedReply
|
||||
}
|
||||
const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
|
||||
await expect(importPerSessionJournal(input)).rejects.toMatchObject({
|
||||
refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } }
|
||||
})
|
||||
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
|
||||
expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
|
||||
expect(errors).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a
|
||||
// reopen with the file still there: nothing is copied again, and nothing is lost.
|
||||
it('never copies the same file again after a failed delete, and deletes it on the next open', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
removeFails()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
const journal = await openChat()
|
||||
await journal.appendItem(
|
||||
item(2),
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true)
|
||||
// The process exits and the database closes.
|
||||
await journals.closeAll()
|
||||
await removeWorks()
|
||||
|
||||
const reopened = await openChat()
|
||||
expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 })
|
||||
expect(texts(reopened)).toContain('after the upgrade')
|
||||
expect(await leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
// T-import-transient: a read that fails leaves the file for the next open, which imports it.
|
||||
it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 })
|
||||
|
||||
await expect(
|
||||
importPerSessionJournal({
|
||||
database,
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
openSource: () => {
|
||||
throw io
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(io)
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true)
|
||||
expect(
|
||||
database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get()
|
||||
).toMatchObject({ total: 0 })
|
||||
|
||||
const journal = await openChat()
|
||||
expect(journal.cursor()).toEqual({ epoch, sequence: rows.length })
|
||||
expect(existsSync(legacyDir())).toBe(false)
|
||||
})
|
||||
|
||||
// A file that is not a database stays where it is, and the chat says it cannot be loaded.
|
||||
it('refuses the open of a legacy file that is not a database, and keeps the file', async () => {
|
||||
await mkdir(legacyDir(), { recursive: true })
|
||||
await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512))
|
||||
|
||||
await expect(openChat()).rejects.toMatchObject({ errcode: 26 })
|
||||
|
||||
expect(await readdir(legacyDir())).toEqual(['journal.db'])
|
||||
})
|
||||
|
||||
it('leaves a never-written file in place until the chat it belongs to is founded', async () => {
|
||||
await writeLegacyJournal('unused', [])
|
||||
await mkdir(legacyDir(), { recursive: true })
|
||||
await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8')
|
||||
|
||||
const journal = await openChat()
|
||||
|
||||
// The pre-SQLite transcript beside it is still there to explain the empty chat.
|
||||
expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl')
|
||||
await journals.closeAll()
|
||||
await openChat()
|
||||
// The journal file goes; the transcript is the user's, and stays.
|
||||
expect(await readdir(legacyDir())).toEqual(['log.jsonl'])
|
||||
})
|
||||
|
||||
// A crash between creating the file and giving it the schema: the chat opens with no history, as
|
||||
// it did when each chat opened its own file, and the file is deleted like any never-written one.
|
||||
it.each([
|
||||
['empty', async (path: string) => writeFile(path, '')],
|
||||
[
|
||||
'schema-less',
|
||||
async (path: string) => {
|
||||
const db = new Database(path)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.close()
|
||||
}
|
||||
]
|
||||
])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => {
|
||||
await mkdir(legacyDir(), { recursive: true })
|
||||
await create(legacyJournalDatabaseFile(legacyDir()))
|
||||
|
||||
const journal = await openChat()
|
||||
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
await journals.closeAll()
|
||||
await openChat()
|
||||
expect(await leftovers()).toEqual([])
|
||||
})
|
||||
|
||||
// F-L6-1: the copy deleted the file, so the older build found none and started the chat over.
|
||||
// The re-upgrade must neither let that start replace the history nor delete what it wrote.
|
||||
it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => {
|
||||
const first = await historyRows('epoch-original', 'ORIGINAL HISTORY')
|
||||
await writeLegacyJournal(first.epoch, first.rows)
|
||||
expect(texts(await openChat())).toContain('ORIGINAL HISTORY')
|
||||
await journals.closeAll()
|
||||
expect(existsSync(legacyDir())).toBe(false)
|
||||
const older = await historyRows('epoch-older-fresh', 'typed in the older build')
|
||||
await writeLegacyJournal(older.epoch, older.rows)
|
||||
const file = legacyJournalDatabaseFile(legacyDir())
|
||||
const bytes = await readFile(file)
|
||||
|
||||
for (let open = 0; open < 2; open += 1) {
|
||||
const reopened = await openChat()
|
||||
expect(reopened.epoch).toBe(first.epoch)
|
||||
expect(texts(reopened)).toContain('ORIGINAL HISTORY')
|
||||
expect(texts(reopened)).not.toContain('typed in the older build')
|
||||
await journals.closeAll()
|
||||
}
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows)
|
||||
expect((await readFile(file)).equals(bytes)).toBe(true)
|
||||
const kept = new Database(file, { readonly: true })
|
||||
const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all()
|
||||
kept.close()
|
||||
expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson))
|
||||
})
|
||||
|
||||
// The older build started over and then rewound, so its file's epoch opens `handle_forked`: it
|
||||
// still never held this build's history, and is set aside like any other epoch.
|
||||
it('keeps the history when the older build rewound the chat it started over', async () => {
|
||||
const first = await historyRows('epoch-original', 'ORIGINAL HISTORY')
|
||||
await writeLegacyJournal(first.epoch, first.rows)
|
||||
await openChat()
|
||||
await journals.closeAll()
|
||||
const older = await historyRows('epoch-older-rewound', 'typed in the older build')
|
||||
const opening = JSON.parse(older.rows[0]!.rowJson)
|
||||
expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' })
|
||||
const rewound = [
|
||||
{ ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) },
|
||||
...older.rows.slice(1)
|
||||
]
|
||||
await writeLegacyJournal(older.epoch, rewound)
|
||||
const bytes = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
|
||||
const reopened = await openChat()
|
||||
|
||||
expect(reopened.epoch).toBe(first.epoch)
|
||||
expect(texts(reopened)).toContain('ORIGINAL HISTORY')
|
||||
expect(texts(reopened)).not.toContain('typed in the older build')
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true)
|
||||
})
|
||||
|
||||
// The one file at another epoch that did descend from the copy: its delete failed and the older
|
||||
// build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost.
|
||||
it('sets aside a kept file the older build rolled to a new epoch', async () => {
|
||||
const first = await historyRows('epoch-original', 'ORIGINAL HISTORY')
|
||||
await writeLegacyJournal(first.epoch, first.rows)
|
||||
removeFails()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
await openChat()
|
||||
await journals.closeAll()
|
||||
await removeWorks()
|
||||
const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build')
|
||||
await rm(legacyDir(), { recursive: true, force: true })
|
||||
await writeLegacyJournal(rolled.epoch, rolled.rows)
|
||||
|
||||
const reopened = await openChat()
|
||||
|
||||
expect(reopened.epoch).toBe(first.epoch)
|
||||
expect(texts(reopened)).toContain('ORIGINAL HISTORY')
|
||||
expect(texts(reopened)).not.toContain('rolled in the older build')
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true)
|
||||
})
|
||||
|
||||
// A chat this build founded has a pointer and no import marker: an older build that then starts
|
||||
// a per-chat file for it never held this build's history, so the file is set aside.
|
||||
it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => {
|
||||
const founded = await openChat()
|
||||
await founded.appendItem(
|
||||
item(1),
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
const foundedEpoch = founded.epoch
|
||||
await journals.closeAll()
|
||||
const older = await historyRows('epoch-older-fresh', 'typed in the older build')
|
||||
await writeLegacyJournal(older.epoch, older.rows)
|
||||
const bytes = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
|
||||
const reopened = await openChat()
|
||||
|
||||
expect(reopened.epoch).toBe(foundedEpoch)
|
||||
expect(texts(reopened)).toContain('FOUNDED HERE')
|
||||
expect(texts(reopened)).not.toContain('typed in the older build')
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true)
|
||||
})
|
||||
|
||||
// Decided once and recorded: no later open reads the file again, after a restart or after the
|
||||
// older build ran again and wrote more to it.
|
||||
it('never reads a set-aside file again', async () => {
|
||||
const first = await historyRows('epoch-original', 'ORIGINAL HISTORY')
|
||||
await writeLegacyJournal(first.epoch, first.rows)
|
||||
await openChat()
|
||||
await journals.closeAll()
|
||||
const older = await historyRows('epoch-older-fresh', 'typed in the older build')
|
||||
await writeLegacyJournal(older.epoch, older.rows)
|
||||
const reads: string[] = []
|
||||
const countingSource = (path: string) => {
|
||||
reads.push(path)
|
||||
return new Database(path, { readonly: true, fileMustExist: true })
|
||||
}
|
||||
const importAgain = () =>
|
||||
importPerSessionJournal({
|
||||
database: openTestJournalHostDatabase(root),
|
||||
identity: IDENTITY,
|
||||
legacyDirectory: legacyDir(),
|
||||
openSource: countingSource
|
||||
})
|
||||
|
||||
expect(await importAgain()).toBe('kept')
|
||||
expect(reads).toHaveLength(1)
|
||||
closeTestJournalHostDatabases()
|
||||
expect(await importAgain()).toBe('kept')
|
||||
const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 }
|
||||
const legacy = new Database(legacyJournalDatabaseFile(legacyDir()))
|
||||
legacy
|
||||
.prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)')
|
||||
.run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row))
|
||||
legacy.close()
|
||||
expect(await importAgain()).toBe('kept')
|
||||
|
||||
expect(reads).toHaveLength(1)
|
||||
expect(texts(await openChat())).toContain('ORIGINAL HISTORY')
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true)
|
||||
})
|
||||
|
||||
// T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a
|
||||
// re-upgrade — twice. This build's history stays whole each time, and the file stays on disk.
|
||||
it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => {
|
||||
const first = await historyRows('epoch-original', 'ORIGINAL HISTORY')
|
||||
await writeLegacyJournal(first.epoch, first.rows)
|
||||
const upgraded = await openChat()
|
||||
await upgraded.appendItem(
|
||||
item(2),
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journals.closeAll()
|
||||
const older = await historyRows('epoch-downgrade', 'older build, cycle 1')
|
||||
await writeLegacyJournal(older.epoch, older.rows)
|
||||
|
||||
for (const cycle of [1, 2]) {
|
||||
if (cycle === 2) {
|
||||
// The second downgrade finds the file it wrote the first time, and carries it on.
|
||||
const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 }
|
||||
row.body = {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'cycle 2' }]
|
||||
}
|
||||
const legacy = new Database(legacyJournalDatabaseFile(legacyDir()))
|
||||
legacy
|
||||
.prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)')
|
||||
.run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row))
|
||||
legacy.close()
|
||||
}
|
||||
const reopened = await openChat()
|
||||
expect(reopened.epoch).toBe(first.epoch)
|
||||
expect(texts(reopened)).toContain('ORIGINAL HISTORY')
|
||||
expect(texts(reopened)).toContain('this build')
|
||||
expect(texts(reopened)).not.toContain('older build, cycle 1')
|
||||
expect(texts(reopened)).not.toContain('cycle 2')
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true)
|
||||
await journals.closeAll()
|
||||
}
|
||||
})
|
||||
|
||||
// The delete failed, so the older build found the copied file and carried its epoch on, while
|
||||
// this build wrote nothing past the copy. This build's history still wins: the file is set aside.
|
||||
it('sets aside a file an older build carried on under the copied epoch', async () => {
|
||||
const { epoch, rows } = await historyRows()
|
||||
await writeLegacyJournal(epoch, rows)
|
||||
removeFails()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
await openChat()
|
||||
await journals.closeAll()
|
||||
appendOlderRow(epoch, rows, rows.length + 1)
|
||||
const bytes = await readFile(legacyJournalDatabaseFile(legacyDir()))
|
||||
await removeWorks()
|
||||
|
||||
const reopened = await openChat()
|
||||
|
||||
expect(reopened.epoch).toBe(epoch)
|
||||
expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length })
|
||||
expect(texts(reopened)).not.toContain('older')
|
||||
expect(sharedRowsContaining('older')).toBe(0)
|
||||
expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true)
|
||||
})
|
||||
|
||||
/** The older build appends one row to the per-chat file the failed delete left, at its epoch. */
|
||||
function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void {
|
||||
const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq }
|
||||
olderRow.body = {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'older' }]
|
||||
}
|
||||
olderRow.itemId = `${olderRow.itemId}-older`
|
||||
const legacy = new Database(legacyJournalDatabaseFile(legacyDir()))
|
||||
legacy
|
||||
.prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)')
|
||||
.run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow))
|
||||
legacy.close()
|
||||
}
|
||||
|
||||
function sharedRowsContaining(text: string): number {
|
||||
return openTestJournalHostDatabase(root)
|
||||
.db.prepare('SELECT row_json FROM journal_rows')
|
||||
.all()
|
||||
.filter((row) => String(row.row_json).includes(text)).length
|
||||
}
|
||||
})
|
||||
@@ -1,304 +0,0 @@
|
||||
// Copying a chat's per-chat journal file into the host's one database, on that chat's open.
|
||||
//
|
||||
// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own
|
||||
// earlier `<legacyDir>/journal.db`, verbatim: the same epoch UUID and every sequence number, so a
|
||||
// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the
|
||||
// same row after it. A file that reappears after a downgrade is set aside, never read again (see
|
||||
// journal-per-session-reimport.ts).
|
||||
//
|
||||
// The copy runs in bounded batches, each its own transaction, yielding the event loop between them.
|
||||
// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader
|
||||
// sees them. Once they read back as the file does (every row's sequence, time and bytes), one
|
||||
// transaction publishes the chat's pointer with its import marker, so the chat is imported all at
|
||||
// once or not at all. A try that stops midway leaves only unpublished rows, which the next try
|
||||
// deletes before it copies again. A copy that does not read back as the file, or a file whose
|
||||
// history is damaged, is never published: the file stays, and the chat is refused as unloadable.
|
||||
//
|
||||
// Only after that commit is the file deleted, its connection closed first. A read that fails
|
||||
// leaves the file where it is for the next open, and the open is refused rather than served empty:
|
||||
// an empty chat founded here would take a new epoch the next open's import could not reconcile.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { JournalHostDatabase } from './journal-host-database'
|
||||
import { startJournalRowFold, type JournalLoad } from './journal-open'
|
||||
import {
|
||||
failLoadOnUnloadableJournal,
|
||||
JournalImportMismatchError,
|
||||
journalOpenRefusalError
|
||||
} from './journal-open-failure'
|
||||
import { legacyJournalDatabaseFile } from './journal-paths'
|
||||
import {
|
||||
planPerSessionImport,
|
||||
isPerSessionJournalSetAside,
|
||||
readPerSessionImportMarker,
|
||||
setAsidePerSessionJournal,
|
||||
writePerSessionImportMarker,
|
||||
type PerSessionImportPlan,
|
||||
type PerSessionJournalHead
|
||||
} from './journal-per-session-reimport'
|
||||
import {
|
||||
foldLegacyJournal,
|
||||
IMPORT_BATCH_ROWS,
|
||||
legacyRowBatches,
|
||||
openLegacySource,
|
||||
readLegacyHead,
|
||||
retireLegacyJournal,
|
||||
type ImportBatch
|
||||
} from './journal-per-session-source'
|
||||
import { parseJournalRow } from './journal-row-schema'
|
||||
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
|
||||
import {
|
||||
deleteUnpublishedJournalRows,
|
||||
publishJournalSessionEpoch,
|
||||
readJournalRowsAfter,
|
||||
readJournalSessionEpoch
|
||||
} from './journal-row-table'
|
||||
|
||||
const INSERT_ROW =
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
|
||||
type PerSessionJournalImportDeps = {
|
||||
openSource?: (path: string) => Database.Database
|
||||
/** Deletes one of the per-chat files. */
|
||||
remove?: (path: string) => void
|
||||
batchRows?: number
|
||||
}
|
||||
|
||||
export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept'
|
||||
|
||||
type ImportInput = {
|
||||
database: JournalHostDatabase
|
||||
identity: AgentSessionJournalIdentity
|
||||
legacyDirectory: string
|
||||
} & PerSessionJournalImportDeps
|
||||
|
||||
/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */
|
||||
const importsInFlight = new WeakMap<JournalHostDatabase, Map<string, Promise<unknown>>>()
|
||||
|
||||
export function importPerSessionJournal(
|
||||
input: ImportInput
|
||||
): Promise<PerSessionJournalImportOutcome> {
|
||||
let inFlight = importsInFlight.get(input.database)
|
||||
if (!inFlight) {
|
||||
inFlight = new Map()
|
||||
importsInFlight.set(input.database, inFlight)
|
||||
}
|
||||
const { sessionId } = input.identity
|
||||
const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input))
|
||||
const settled = run.catch(() => undefined)
|
||||
inFlight.set(sessionId, settled)
|
||||
void settled.then(() => {
|
||||
if (inFlight.get(sessionId) === settled) {
|
||||
inFlight.delete(sessionId)
|
||||
}
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
async function importOnce(input: ImportInput): Promise<PerSessionJournalImportOutcome> {
|
||||
const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory)
|
||||
if (!existsSync(sourcePath)) {
|
||||
return 'absent'
|
||||
}
|
||||
const { sessionId } = input.identity
|
||||
if (isPerSessionJournalSetAside(input.database.db, sessionId)) {
|
||||
return 'kept'
|
||||
}
|
||||
const published = readJournalSessionEpoch(input.database.db, sessionId) !== null
|
||||
const source = (input.openSource ?? openLegacySource)(sourcePath)
|
||||
let legacy: PerSessionJournalHead | null
|
||||
let plan: PerSessionImportPlan | null = null
|
||||
try {
|
||||
legacy = readLegacyHead(source, sessionId)
|
||||
if (legacy) {
|
||||
plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published })
|
||||
if (plan.kind === 'first') {
|
||||
await copyLegacyJournal(input, source, legacy)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
source.close()
|
||||
}
|
||||
if (!legacy) {
|
||||
// Never written. Left in place while its chat is unfounded: that open's empty chat may still
|
||||
// owe the notice about a pre-SQLite transcript beside it.
|
||||
if (!published) {
|
||||
return 'absent'
|
||||
}
|
||||
retireLegacyJournal(input.legacyDirectory, input.remove)
|
||||
return 'already-imported'
|
||||
}
|
||||
if (plan?.kind === 'kept') {
|
||||
setAsidePerSessionJournal(input.database.db, sessionId, legacy)
|
||||
return 'kept'
|
||||
}
|
||||
// Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again.
|
||||
retireLegacyJournal(input.legacyDirectory, input.remove)
|
||||
if (plan?.kind === 'copied') {
|
||||
return 'already-imported'
|
||||
}
|
||||
// The open's replay of what was just copied is a long task of its own; don't add this one to it.
|
||||
await yieldToEventLoop()
|
||||
return 'imported'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat a first copy would import, folded straight from its per-chat file and copying nothing:
|
||||
* for a restore, which must not import. Null when the open has to import now instead: the chat is
|
||||
* already in the host's database or was copied before (the reimport rules decide), its file holds
|
||||
* no chat or no rows, or a newer build wrote it. A damaged file fails the load, copied nowhere. The
|
||||
* file is closed before this returns.
|
||||
*/
|
||||
export async function previewPerSessionJournal(
|
||||
input: Pick<ImportInput, 'database' | 'identity' | 'legacyDirectory' | 'openSource'>
|
||||
): Promise<JournalLoad | null> {
|
||||
const { sessionId } = input.identity
|
||||
const db = input.database.db
|
||||
const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory)
|
||||
if (
|
||||
readJournalSessionEpoch(db, sessionId) !== null ||
|
||||
readPerSessionImportMarker(db, sessionId) ||
|
||||
!existsSync(sourcePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const source = (input.openSource ?? openLegacySource)(sourcePath)
|
||||
try {
|
||||
const legacy = readLegacyHead(source, sessionId)
|
||||
if (!legacy) {
|
||||
return null
|
||||
}
|
||||
const loaded = await foldLegacyJournal(source, sessionId, legacy)
|
||||
failLoadOnUnloadableJournal(sessionId, loaded)
|
||||
// An empty one is copied now, so its open founds the chat's epoch in the host's database.
|
||||
return loaded.state.lastSequence === 0 ? null : loaded
|
||||
} finally {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each
|
||||
* batch is folded as it is copied, so a damaged file is refused before anything is published. Once
|
||||
* the rows read back as the file does, one transaction publishes the pointer and the import marker.
|
||||
*/
|
||||
async function copyLegacyJournal(
|
||||
input: ImportInput,
|
||||
source: Database.Database,
|
||||
legacy: PerSessionJournalHead
|
||||
): Promise<void> {
|
||||
const { sessionId } = input.identity
|
||||
const { epoch } = legacy
|
||||
const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS
|
||||
const fold = startJournalRowFold({ sessionId, epoch })
|
||||
let folding = true
|
||||
let first = true
|
||||
for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) {
|
||||
if (!first) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
folding &&= batch.rows.every(fold.add)
|
||||
// Unsynced: no reader follows these rows, and the publish's synced commit covers them.
|
||||
input.database.unsyncedTransaction((db) => {
|
||||
if (first) {
|
||||
// What an earlier try that stopped midway left.
|
||||
deleteUnpublishedJournalRows(db, sessionId)
|
||||
}
|
||||
const insert = db.prepare(INSERT_ROW)
|
||||
for (const row of batch.rows) {
|
||||
// Copied as stored: the bytes are the row, its epoch and sequence included.
|
||||
insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson)
|
||||
}
|
||||
})
|
||||
first = false
|
||||
}
|
||||
const copied = fold.finish()
|
||||
if (copied.damage || copied.newer) {
|
||||
// Never published, so the copy goes; the file it came from stays.
|
||||
input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId))
|
||||
}
|
||||
failLoadOnUnloadableJournal(sessionId, copied)
|
||||
await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch)
|
||||
input.database.transaction((db) => {
|
||||
publishJournalSessionEpoch(db, input.identity, epoch)
|
||||
writePerSessionImportMarker(db, sessionId, legacy)
|
||||
})
|
||||
}
|
||||
|
||||
/** Mismatches already logged, so a chat refused on every open logs once. */
|
||||
const loggedMismatches = new Set<string>()
|
||||
|
||||
/**
|
||||
* The copied rows, read back from the host's database, against a second read of what was copied:
|
||||
* the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the
|
||||
* copy is refused and never published. Both reads go a batch at a time, so no check holds the main
|
||||
* thread longer than a copy batch does.
|
||||
*/
|
||||
async function verifyCopiedJournal(
|
||||
input: ImportInput,
|
||||
expected: Iterable<ImportBatch>,
|
||||
epoch: string
|
||||
): Promise<void> {
|
||||
const { sessionId } = input.identity
|
||||
const want = await copyFacts(sessionId, expected)
|
||||
const got = await copyFacts(sessionId, copiedBatches(input, epoch))
|
||||
if (want === got) {
|
||||
return
|
||||
}
|
||||
const error = new JournalImportMismatchError(
|
||||
`per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}`
|
||||
)
|
||||
if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) {
|
||||
loggedMismatches.add(`${sessionId}\n${want}\n${got}`)
|
||||
console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`)
|
||||
}
|
||||
throw journalOpenRefusalError(error)
|
||||
}
|
||||
|
||||
/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */
|
||||
async function copyFacts(sessionId: string, batches: Iterable<ImportBatch>): Promise<string> {
|
||||
const state = createJournalReducerState(sessionId, '')
|
||||
const content = createHash('sha256')
|
||||
let epoch: string | null = null
|
||||
let tip = 0
|
||||
let rows = 0
|
||||
let first = true
|
||||
for (const batch of batches) {
|
||||
if (!first) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
first = false
|
||||
rows += batch.rows.length
|
||||
for (const row of batch.rows) {
|
||||
tip = Math.max(tip, row.seq)
|
||||
// Length-framed, so no two different rows hash the same stream.
|
||||
content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson)
|
||||
const parsed = parseJournalRow(row.rowJson)
|
||||
if (parsed.ok) {
|
||||
epoch ??= parsed.row.epoch
|
||||
applyJournalRow(state, parsed.row)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}`
|
||||
}
|
||||
|
||||
function* copiedBatches(input: ImportInput, epoch: string): Generator<ImportBatch> {
|
||||
const { sessionId } = input.identity
|
||||
const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS
|
||||
let afterSeq = Number.MIN_SAFE_INTEGER
|
||||
for (;;) {
|
||||
const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows)
|
||||
const lastSeq = rows.at(-1)?.seq
|
||||
const last = rows.length < batchRows || lastSeq === undefined
|
||||
yield { rows, last }
|
||||
if (last) {
|
||||
return
|
||||
}
|
||||
afterSeq = lastSeq
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// A per-chat file that reappears after its chat was copied in: an older build, run after a
|
||||
// downgrade, attached the chat and wrote its history there.
|
||||
//
|
||||
// `journal_imports` records which file each chat was copied from — its epoch and tip — in the
|
||||
// transaction that publishes the verified copy. A file still at that epoch and tip was already
|
||||
// copied (only its delete failed, or a crash came first, across any number of restarts): it is
|
||||
// deleted, never copied again. This build's history always wins: once a chat has been copied or
|
||||
// founded here, any other file is set aside, left on disk as it is, and recorded in
|
||||
// `journal_set_aside`, so no later open reads it again.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
|
||||
export type PerSessionJournalHead = { epoch: string; tip: number }
|
||||
|
||||
const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?'
|
||||
const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip`
|
||||
|
||||
export function readPerSessionImportMarker(
|
||||
db: Database.Database,
|
||||
sessionId: string
|
||||
): PerSessionJournalHead | null {
|
||||
const row = db.prepare(SELECT_MARKER).get(sessionId)
|
||||
return typeof row?.epoch === 'string' && typeof row.tip === 'number'
|
||||
? { epoch: row.epoch, tip: row.tip }
|
||||
: null
|
||||
}
|
||||
|
||||
const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?'
|
||||
const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id) DO NOTHING`
|
||||
|
||||
export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean {
|
||||
return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined
|
||||
}
|
||||
|
||||
/** Records a file that is not this build's history, as it was when set aside. */
|
||||
export function setAsidePerSessionJournal(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
head: PerSessionJournalHead
|
||||
): void {
|
||||
db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip)
|
||||
}
|
||||
|
||||
export function writePerSessionImportMarker(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
head: PerSessionJournalHead
|
||||
): void {
|
||||
db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip)
|
||||
}
|
||||
|
||||
export type PerSessionImportPlan =
|
||||
| { kind: 'first' }
|
||||
| { kind: 'copied' }
|
||||
/** Not this build's history: set aside, neither copied nor deleted. */
|
||||
| { kind: 'kept' }
|
||||
|
||||
/** What a present per-chat file owes this chat, judged against what was last copied from it. */
|
||||
export function planPerSessionImport(input: {
|
||||
db: Database.Database
|
||||
sessionId: string
|
||||
legacy: PerSessionJournalHead
|
||||
/** The chat already has an epoch in the host's database. */
|
||||
published: boolean
|
||||
}): PerSessionImportPlan {
|
||||
const marker = readPerSessionImportMarker(input.db, input.sessionId)
|
||||
if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) {
|
||||
return { kind: 'copied' }
|
||||
}
|
||||
return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' }
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Reading a chat's per-chat journal file, the one each chat had before the host's one database.
|
||||
// The importer copies through this reader, and a restore folds through it without copying.
|
||||
|
||||
import { rmdirSync, rmSync } from 'node:fs'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { startJournalRowFold, type JournalLoad } from './journal-open'
|
||||
import { legacyJournalDatabaseFile } from './journal-paths'
|
||||
import type { PerSessionJournalHead } from './journal-per-session-reimport'
|
||||
|
||||
/** The newest per-chat file shape any build wrote. */
|
||||
const LEGACY_JOURNAL_SCHEMA_VERSION = 2
|
||||
/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */
|
||||
export const IMPORT_BATCH_ROWS = 512
|
||||
|
||||
const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?'
|
||||
const SELECT_LEGACY_TIP =
|
||||
'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?'
|
||||
const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows
|
||||
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?`
|
||||
const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?"
|
||||
|
||||
type ImportedRow = { seq: number; ts: number; rowJson: string }
|
||||
export type ImportBatch = { rows: ImportedRow[]; last: boolean }
|
||||
|
||||
/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */
|
||||
export function openLegacySource(path: string): Database.Database {
|
||||
const source = new Database(path, { readonly: true, fileMustExist: true })
|
||||
try {
|
||||
const version = Number(source.pragma('user_version', { simple: true }) ?? 0)
|
||||
if (version > LEGACY_JOURNAL_SCHEMA_VERSION) {
|
||||
throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`)
|
||||
}
|
||||
return source
|
||||
} catch (error) {
|
||||
source.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function readLegacyHead(
|
||||
source: Database.Database,
|
||||
sessionId: string
|
||||
): PerSessionJournalHead | null {
|
||||
// Created but never given its schema (a crash between the two): no history, as an empty file.
|
||||
if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) {
|
||||
return null
|
||||
}
|
||||
const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch
|
||||
if (typeof epoch !== 'string' || epoch.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip
|
||||
return { epoch, tip: typeof tip === 'number' ? tip : 0 }
|
||||
}
|
||||
|
||||
/** The file's rows, one bounded page per batch, read as each batch is written. */
|
||||
export function* legacyRowBatches(
|
||||
source: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
batchRows: number
|
||||
): Generator<ImportBatch> {
|
||||
const select = source.prepare(SELECT_LEGACY_ROWS)
|
||||
let afterSeq = Number.MIN_SAFE_INTEGER
|
||||
for (;;) {
|
||||
const rows = select
|
||||
.all(sessionId, epoch, afterSeq, batchRows)
|
||||
.map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) }))
|
||||
const lastSeq = rows.at(-1)?.seq
|
||||
const last = rows.length < batchRows || lastSeq === undefined
|
||||
yield { rows, last }
|
||||
if (last) {
|
||||
return
|
||||
}
|
||||
afterSeq = lastSeq
|
||||
}
|
||||
}
|
||||
|
||||
/** The chat as its file holds it, folded the way a replay folds the host's database. */
|
||||
export async function foldLegacyJournal(
|
||||
source: Database.Database,
|
||||
sessionId: string,
|
||||
legacy: PerSessionJournalHead
|
||||
): Promise<JournalLoad> {
|
||||
const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch })
|
||||
let first = true
|
||||
for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) {
|
||||
// A batch per turn: a large chat's file read in one task holds up everything else at startup.
|
||||
if (!first) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
first = false
|
||||
if (!batch.rows.every(fold.add)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return fold.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is
|
||||
* in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed.
|
||||
* Best effort: the copy is committed, so a file left behind is deleted by the next open.
|
||||
*/
|
||||
export function retireLegacyJournal(
|
||||
legacyDirectory: string,
|
||||
remove: (path: string) => void = (path) => rmSync(path, { force: true })
|
||||
): void {
|
||||
const file = legacyJournalDatabaseFile(legacyDirectory)
|
||||
try {
|
||||
// The database first: a WAL left without it is never read, but a database left without its WAL
|
||||
// would read back short of the tip it was copied at, and be set aside rather than deleted.
|
||||
for (const path of [file, `${file}-wal`, `${file}-shm`]) {
|
||||
remove(path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmdirSync(legacyDirectory)
|
||||
} catch {
|
||||
// Not empty: something beside the journal is kept.
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,6 @@ export function cancelledJournalPromptBody(
|
||||
}
|
||||
}
|
||||
|
||||
export function boundJournalStatusText(text: string): string {
|
||||
return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
|
||||
}
|
||||
|
||||
export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem
|
||||
export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem
|
||||
export function boundJournalPromptBody(
|
||||
|
||||
@@ -344,7 +344,7 @@ export class JournalQueuedMessages {
|
||||
* no hook), then retention runs.
|
||||
*/
|
||||
repairAndPrune(): Promise<void> {
|
||||
// No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied.
|
||||
// No draft, no work, and no write.
|
||||
if (this.deps.readOnly() || this.list().length === 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows
|
||||
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC`
|
||||
const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?`
|
||||
const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?'
|
||||
const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ?
|
||||
AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)`
|
||||
|
||||
export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null {
|
||||
const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch
|
||||
@@ -106,8 +104,3 @@ export function deleteJournalEpochRows(
|
||||
): void {
|
||||
db.prepare(DELETE_EPOCH).run(sessionId, epoch)
|
||||
}
|
||||
|
||||
/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */
|
||||
export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void {
|
||||
db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId)
|
||||
}
|
||||
|
||||
@@ -30,15 +30,9 @@ export type JournalStoreHost = {
|
||||
* same way they learn of a row. */
|
||||
notifyCommitted: () => void
|
||||
identity: AgentSessionJournalIdentity
|
||||
/** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */
|
||||
legacyDirectory: string
|
||||
now: () => number
|
||||
mintEpoch: () => string
|
||||
serialize: <T>(run: JournalWriteBody<T>) => Promise<T>
|
||||
/** Leave a chat still in its per-chat file uncopied until its first use. */
|
||||
deferPerSessionImport: boolean
|
||||
/** Work the chat's next write waits for. */
|
||||
owe: (work: () => Promise<void>) => void
|
||||
database: () => JournalHostDatabase
|
||||
state: () => JournalReducerState
|
||||
readOnly: () => boolean
|
||||
|
||||
@@ -21,8 +21,6 @@ export type AgentSessionJournalOptions = {
|
||||
database: JournalHostDatabase
|
||||
now?: () => number
|
||||
mintEpoch?: () => string
|
||||
/** A restore's open: see `AgentSessionJournal.whenImported`. */
|
||||
deferPerSessionImport?: boolean
|
||||
}
|
||||
|
||||
export type JournalReadSince =
|
||||
|
||||
@@ -2,20 +2,12 @@ import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentType } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
findJournalFileFormatRemnant,
|
||||
journalFileFormatRemnantDisclosure
|
||||
} from './journal-file-format-remnant'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { failLoadOnUnloadableJournal } from './journal-open-failure'
|
||||
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
|
||||
|
||||
type JournalDisclosure = ReturnType<typeof journalFileFormatRemnantDisclosure>
|
||||
|
||||
export async function openJournalStoreState(input: {
|
||||
sessionId: string
|
||||
legacyDirectory: string
|
||||
replay: () => JournalLoad | null
|
||||
start: () => void
|
||||
adopt: (loaded: JournalLoad) => void
|
||||
@@ -24,7 +16,6 @@ export async function openJournalStoreState(input: {
|
||||
body: AgentJournalItemBody,
|
||||
fence: number
|
||||
) => Promise<unknown>
|
||||
agent: AgentType
|
||||
highestFence: () => number
|
||||
}): Promise<void> {
|
||||
const loaded = input.replay()
|
||||
@@ -32,41 +23,11 @@ export async function openJournalStoreState(input: {
|
||||
// keep, so it is founded afresh like a chat with no journal; no row is deleted.
|
||||
if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) {
|
||||
input.start()
|
||||
await discloseFileFormatRemnant(input)
|
||||
return
|
||||
}
|
||||
failLoadOnUnloadableJournal(input.sessionId, loaded)
|
||||
input.adopt(loaded)
|
||||
await settleStaleSubagentRosters(input, loaded)
|
||||
// Founding the epoch and appending the row are two transactions, and a
|
||||
// committed epoch sends every later open down this branch instead. Anything
|
||||
// that interrupts between them — a quit during startup restore, a failed
|
||||
// append — would otherwise lose the message for good. An epoch holding nothing
|
||||
// is exactly the state that append was owed, so offer it again.
|
||||
if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) {
|
||||
await discloseFileFormatRemnant(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what happened to a chat whose history is in the abandoned file format.
|
||||
* Upserts by a constant identity, so the offer above is exactly-once in effect:
|
||||
* once the row exists the epoch is no longer empty. */
|
||||
async function discloseFileFormatRemnant(input: {
|
||||
legacyDirectory: string
|
||||
agent: AgentType
|
||||
appendItem: (
|
||||
identity: JournalDisclosure['identity'],
|
||||
body: JournalDisclosure['body'],
|
||||
fence: number
|
||||
) => Promise<unknown>
|
||||
highestFence: () => number
|
||||
}): Promise<void> {
|
||||
const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory)
|
||||
if (!transcriptPath) {
|
||||
return
|
||||
}
|
||||
const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent })
|
||||
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
|
||||
}
|
||||
|
||||
/** Retires a `working` subagent roster the previous host never got to settle. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Bringing a store's in-memory state up from disk.
|
||||
//
|
||||
// Split out of the store for the same reason its collaborators were: this is the
|
||||
// ORDERING between import, replay and disclosure, and none of it belongs
|
||||
// ORDERING between replay and the notices an open owes, and none of it belongs
|
||||
// to the store's public surface. Every step here reads or writes through the
|
||||
// same host the collaborators use, so the store keeps the state and this owns
|
||||
// the sequence.
|
||||
@@ -9,22 +9,17 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalEpochController } from './journal-epoch-controller'
|
||||
import { replayJournal } from './journal-open'
|
||||
import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure'
|
||||
import { journalOpenRefusalError } from './journal-open-failure'
|
||||
import type { JournalStoreHost } from './journal-store-collaborators'
|
||||
import { openJournalStoreState } from './journal-store-open'
|
||||
import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
|
||||
export async function restoreJournalStore(
|
||||
host: JournalStoreHost,
|
||||
collaborators: { epochController: JournalEpochController }
|
||||
): Promise<void> {
|
||||
const source = {
|
||||
database: host.database(),
|
||||
identity: host.identity,
|
||||
legacyDirectory: host.legacyDirectory
|
||||
}
|
||||
if (source.database.readOnly) {
|
||||
const database = host.database()
|
||||
if (database.readOnly) {
|
||||
// A newer Orca's database: nothing in it is read as this build's, and nothing is written.
|
||||
throw journalOpenRefusalError(
|
||||
new AgentSessionJournalError(
|
||||
@@ -33,32 +28,14 @@ export async function restoreJournalStore(
|
||||
)
|
||||
)
|
||||
}
|
||||
// A restore reads a chat still in its per-chat file from there, and copies it before its first use.
|
||||
const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null
|
||||
if (preview) {
|
||||
host.owe(async () => {
|
||||
await importPerSessionJournal(source)
|
||||
const imported = replayJournal(source.database.db, host.identity.sessionId)
|
||||
if (!imported) {
|
||||
throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`)
|
||||
}
|
||||
failLoadOnUnloadableJournal(host.identity.sessionId, imported)
|
||||
host.adopt(imported)
|
||||
})
|
||||
} else {
|
||||
// A per-chat file left by an earlier build is this chat's newest history: copied in first.
|
||||
await importPerSessionJournal(source)
|
||||
}
|
||||
return openJournalStoreState({
|
||||
sessionId: host.identity.sessionId,
|
||||
legacyDirectory: host.legacyDirectory,
|
||||
replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId),
|
||||
replay: () => replayJournal(database.db, host.identity.sessionId),
|
||||
start: () => collaborators.epochController.start('session_created', 0),
|
||||
adopt: host.adopt,
|
||||
// File-format and roster notices are about the conversation, not any turn in it.
|
||||
// Roster notices are about the conversation, not any turn in it.
|
||||
appendItem: (identity, body, fence) =>
|
||||
host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }),
|
||||
agent: host.identity.agent,
|
||||
highestFence: () => host.state().highestFence
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from './journal-payload-bounds'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn'
|
||||
import { journalDirectoryFor, journalPathSegment } from './journal-paths'
|
||||
import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store'
|
||||
import type { openAgentSessionJournal } from './journal-store-factory'
|
||||
import {
|
||||
@@ -441,27 +440,6 @@ describe('lifecycle batches', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('journal location', () => {
|
||||
it('keys by workspace and session id rather than by a path in the working tree', () => {
|
||||
const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' })
|
||||
expect(dir).toBe(
|
||||
join(
|
||||
'/state',
|
||||
'agent-session-journal',
|
||||
journalPathSegment('ws/1'),
|
||||
journalPathSegment('sess:2')
|
||||
)
|
||||
)
|
||||
expect(dir).not.toContain('ws/1')
|
||||
})
|
||||
|
||||
it('separates two sessions in one workspace', () => {
|
||||
const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' })
|
||||
const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' })
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('on-disk layout', () => {
|
||||
it('keeps every chat of the state directory in its one database, and no per-chat file', async () => {
|
||||
const journal: AgentSessionJournal = await open()
|
||||
|
||||
@@ -107,12 +107,9 @@ export class AgentSessionJournal {
|
||||
this.queue = new JournalWriteQueue(options.identity.sessionId)
|
||||
const collaborators = createJournalStoreCollaborators({
|
||||
identity: this.identity,
|
||||
legacyDirectory: this.database.legacyDirectoryFor(this.identity),
|
||||
now: this.now,
|
||||
mintEpoch: this.mintEpoch,
|
||||
serialize: (run) => this.queue.serialize(run),
|
||||
deferPerSessionImport: options.deferPerSessionImport === true,
|
||||
owe: (work) => this.queue.owe(work),
|
||||
database: () => this.database,
|
||||
state: () => this.state,
|
||||
// A newer Orca's database: nothing it holds opens, and every write is refused.
|
||||
@@ -177,20 +174,6 @@ export class AgentSessionJournal {
|
||||
this.onCommitted = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the chat's rows are in the host's database. A restore's open serves a chat still
|
||||
* in its per-chat file from a read-only fold of it; the copy runs before the chat's first write.
|
||||
* A reader that needs rows (forward pages, catch-up) and every mutation's open await it here, so
|
||||
* each reads the fold after every earlier write.
|
||||
*/
|
||||
whenImported(): Promise<void> {
|
||||
return this.queue.serialize(() => undefined)
|
||||
}
|
||||
|
||||
get importPending(): boolean {
|
||||
return this.queue.owing
|
||||
}
|
||||
|
||||
cursor = (): AgentJournalCursor => ({
|
||||
epoch: this.state.epoch,
|
||||
sequence: this.state.lastSequence
|
||||
@@ -264,7 +247,7 @@ export class AgentSessionJournal {
|
||||
canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId)
|
||||
|
||||
/** Reads the fold with every write issued before this call committed, and none issued after: at
|
||||
* once unless writes still wait behind an owed import or a running write. */
|
||||
* once unless a write is running or writes wait in line. */
|
||||
readInOrder<T>(read: () => T): Promise<T> {
|
||||
return this.queue.readInOrder(read)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// A write has landed in the fold when its call returns, except during an owed import, when it lands
|
||||
// in queue order. A write issued from inside a running write joins the line behind it, never nested.
|
||||
// A write has landed in the fold when its call returns. A write issued from inside a running write
|
||||
// joins the line behind it, never nested.
|
||||
// A read in the queue always settles: behind a write that failed, and refused once closed.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from './journal-host-database-test-support'
|
||||
import { openJournalOwingImport } from './journal-owed-import-test-support'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
import { JournalWriteQueue } from './journal-write-queue'
|
||||
@@ -67,15 +66,6 @@ async function idleJournal(): Promise<AgentSessionJournal> {
|
||||
return journal
|
||||
}
|
||||
|
||||
async function journalOwingImport(): Promise<{ journal: AgentSessionJournal; history: number }> {
|
||||
const { journal, history } = await openJournalOwingImport({
|
||||
stateDirectory: root,
|
||||
identity: IDENTITY
|
||||
})
|
||||
opened.push(journal)
|
||||
return { journal, history: history.length }
|
||||
}
|
||||
|
||||
function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined {
|
||||
const key = agentJournalItemKey(item(ordinal))
|
||||
let found: number | undefined
|
||||
@@ -101,25 +91,6 @@ describe('when a journal write lands', () => {
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('lands in queue order, behind the history it copies, while an import is owed', async () => {
|
||||
const { journal, history } = await journalOwingImport()
|
||||
|
||||
const first = journal.appendItem(item(1), reply('one'), OPTIONS)
|
||||
const second = journal.appendItem(item(2), reply('two'), OPTIONS)
|
||||
// Neither has landed: both wait behind the copy the first write pays.
|
||||
expect(journal.itemBody(agentJournalItemKey(item(1)))).toBeNull()
|
||||
expect(journal.importPending).toBe(true)
|
||||
|
||||
await second
|
||||
await first
|
||||
expect(journal.importPending).toBe(false)
|
||||
expect(sequenceOf(journal, 1)).toBe(history + 1)
|
||||
expect(sequenceOf(journal, 2)).toBe(history + 2)
|
||||
// The copy is paid: the next write lands at its call again.
|
||||
journal.appendItem(item(3), reply('three'), OPTIONS).catch(() => undefined)
|
||||
expect(sequenceOf(journal, 3)).toBe(history + 3)
|
||||
})
|
||||
|
||||
it('joins the line behind a write it was issued from inside, never nested in it', async () => {
|
||||
const journal = await idleJournal()
|
||||
let inner: Promise<unknown> | null = null
|
||||
@@ -142,7 +113,7 @@ describe('when a journal write lands', () => {
|
||||
})
|
||||
|
||||
describe('the journal write queue', () => {
|
||||
it('runs a write before it returns when nothing is owed or running', () => {
|
||||
it('runs a write before it returns when nothing is running', () => {
|
||||
const queue = new JournalWriteQueue('session-1')
|
||||
const ran: string[] = []
|
||||
void queue.serialize(() => {
|
||||
@@ -187,27 +158,6 @@ describe('the journal write queue', () => {
|
||||
await Promise.all([outer, nested, after])
|
||||
expect(ran).toEqual(['outer', 'nested', 'after'])
|
||||
})
|
||||
|
||||
it('runs every write admitted while work is owed after it, in order', async () => {
|
||||
const queue = new JournalWriteQueue('session-1')
|
||||
const ran: string[] = []
|
||||
const owed = Promise.withResolvers<void>()
|
||||
queue.owe(async () => {
|
||||
await owed.promise
|
||||
ran.push('owed')
|
||||
})
|
||||
const first = queue.serialize(() => {
|
||||
ran.push('first')
|
||||
})
|
||||
const read = queue.readInOrder(() => ran.push('read'))
|
||||
owed.resolve()
|
||||
const second = queue.serialize(() => {
|
||||
ran.push('second')
|
||||
})
|
||||
await Promise.all([first, read, second])
|
||||
expect(ran).toEqual(['owed', 'first', 'read', 'second'])
|
||||
expect(queue.owing).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a read in the journal write queue', () => {
|
||||
@@ -218,29 +168,17 @@ describe('a read in the journal write queue', () => {
|
||||
expect(ran).toEqual(['read'])
|
||||
})
|
||||
|
||||
it('does not pay owed work, and runs behind a write that pays it', async () => {
|
||||
const queue = new JournalWriteQueue('session-1')
|
||||
const ran: string[] = []
|
||||
queue.owe(async () => {
|
||||
ran.push('owed')
|
||||
})
|
||||
void queue.readInOrder(() => ran.push('read before'))
|
||||
expect(ran).toEqual(['read before'])
|
||||
const write = queue.serialize(() => {
|
||||
ran.push('write')
|
||||
})
|
||||
const read = queue.readInOrder(() => ran.push('read after'))
|
||||
await Promise.all([write, read])
|
||||
expect(ran).toEqual(['read before', 'owed', 'write', 'read after'])
|
||||
})
|
||||
|
||||
it('runs behind a write that failed', async () => {
|
||||
const queue = new JournalWriteQueue('session-1')
|
||||
queue.owe(async () => undefined)
|
||||
const failed = queue.serialize(() => {
|
||||
throw new Error('disk I/O error')
|
||||
let failed: Promise<unknown> = Promise.resolve()
|
||||
let read: Promise<string> = Promise.resolve('')
|
||||
// Issued from inside a running write, so both join the line.
|
||||
await queue.serialize(() => {
|
||||
failed = queue.serialize(() => {
|
||||
throw new Error('disk I/O error')
|
||||
})
|
||||
read = queue.readInOrder(() => 'read')
|
||||
})
|
||||
const read = queue.readInOrder(() => 'read')
|
||||
await expect(failed).rejects.toThrow('disk I/O error')
|
||||
await expect(read).resolves.toBe('read')
|
||||
})
|
||||
|
||||
@@ -12,14 +12,13 @@ export type JournalWriteResult<T> = T extends PromiseLike<unknown> ? never : T
|
||||
export type JournalWriteBody<T> = () => JournalWriteResult<T>
|
||||
|
||||
/**
|
||||
* A write has landed in the fold when its call returns, except during an owed import, when it
|
||||
* lands in queue order.
|
||||
* A write has landed in the fold when its call returns, unless it was issued from inside another
|
||||
* write or behind one still waiting in line.
|
||||
*
|
||||
* A write finds the queue idle unless work is owed, a write is running, or writes wait in line;
|
||||
* then it runs before `serialize` returns. Every write body is synchronous (`JournalWriteBody`
|
||||
* refuses a promise), so it has committed by then. Otherwise it joins the line: behind the owed
|
||||
* import it pays first, or behind the running write it was issued from, never nested inside it.
|
||||
* Admission is checked at ENQUEUE and is permanent.
|
||||
* A write finds the queue idle unless a write is running or writes wait in line; then it runs
|
||||
* before `serialize` returns. Every write body is synchronous (`JournalWriteBody` refuses a
|
||||
* promise), so it has committed by then. Otherwise it joins the line behind the writes ahead of
|
||||
* it, never nested inside the running one. Admission is checked at ENQUEUE and is permanent.
|
||||
*/
|
||||
export class JournalWriteQueue {
|
||||
/** Settles once every write admitted so far has, whatever its outcome. */
|
||||
@@ -28,8 +27,6 @@ export class JournalWriteQueue {
|
||||
private waiting = 0
|
||||
private running = false
|
||||
private closed = false
|
||||
/** Runs before the next write, and stays owed until it succeeds. */
|
||||
private owed: (() => Promise<void>) | null = null
|
||||
|
||||
constructor(private readonly sessionId: string) {}
|
||||
|
||||
@@ -41,19 +38,17 @@ export class JournalWriteQueue {
|
||||
if (this.closed) {
|
||||
return Promise.reject(this.closedError())
|
||||
}
|
||||
return this.owed !== null || this.lineBusy
|
||||
? this.join(run, this.owed !== null)
|
||||
: this.runNow(run)
|
||||
return this.lineBusy ? this.join(run) : this.runNow(run)
|
||||
}
|
||||
|
||||
/** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any
|
||||
* admitted after: at once when none waits. Owed work is not paid for a read. A closed queue
|
||||
* refuses it, as it refuses a write: its fold may be replaced. */
|
||||
* admitted after: at once when none waits. A closed queue refuses it, as it refuses a write: its
|
||||
* fold may be replaced. */
|
||||
readInOrder<T>(read: () => T): Promise<T> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(this.closedError())
|
||||
}
|
||||
return this.lineBusy ? this.join(read, false) : this.runNow(read)
|
||||
return this.lineBusy ? this.join(read) : this.runNow(read)
|
||||
}
|
||||
|
||||
private get lineBusy(): boolean {
|
||||
@@ -67,22 +62,6 @@ export class JournalWriteQueue {
|
||||
)
|
||||
}
|
||||
|
||||
owe(work: () => Promise<void>): void {
|
||||
this.owed = work
|
||||
}
|
||||
|
||||
get owing(): boolean {
|
||||
return this.owed !== null
|
||||
}
|
||||
|
||||
private payOwed = async (): Promise<void> => {
|
||||
const owed = this.owed
|
||||
if (owed) {
|
||||
await owed()
|
||||
this.owed = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves once every write admitted so far has settled, whatever its outcome. */
|
||||
drain(): Promise<void> {
|
||||
return this.writes.then(() => undefined)
|
||||
@@ -107,8 +86,8 @@ export class JournalWriteQueue {
|
||||
return result
|
||||
}
|
||||
|
||||
private join<T>(run: () => T, paysOwed: boolean): Promise<T> {
|
||||
const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run)
|
||||
private join<T>(run: () => T): Promise<T> {
|
||||
const started = this.writes.then(run)
|
||||
this.writes = started.catch(() => undefined)
|
||||
this.waiting++
|
||||
const settle = (): void => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export function mutateWithChatStop<TValue>(
|
||||
// Read before the withdrawal it decides on is issued.
|
||||
const hadQueued = ctx.journal.submissions().some(isQueuedAgentJournalSubmission)
|
||||
// Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing.
|
||||
// Issued, not awaited: the interrupt never waits on bookkeeping. (The open paid any owed import.)
|
||||
// Issued, not awaited: the interrupt never waits on bookkeeping.
|
||||
const withdrew = withdrawQueuedForStop(ctx, () =>
|
||||
ctx.journal.rejectQueuedSubmissions(
|
||||
ctx.fence,
|
||||
|
||||
-14
@@ -49,11 +49,6 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
const readRefusals = createJournalOpenReadRefusals(
|
||||
deferredStructuredAgentSessionLogger(() => deps().logger)
|
||||
)
|
||||
// The owed copy fails as an open does: the reader gets the classified refusal, never its text.
|
||||
const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) =>
|
||||
session.journal.whenImported().catch((error: unknown) => {
|
||||
throw readRefusals.refusal(sessionId, error)
|
||||
})
|
||||
const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) =>
|
||||
stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending)
|
||||
|
||||
@@ -118,11 +113,6 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
conversation: async (sessionId: string): Promise<StructuredAgentSessionHostSession> => {
|
||||
const open = sessions.get(sessionId)
|
||||
if (open) {
|
||||
// Restore left its per-chat file uncopied; a reader gets the chat from the one database.
|
||||
// Awaited only then: an open conversation otherwise answers in the same turn.
|
||||
if (open.journal.importPending) {
|
||||
await whenImported(sessionId, open)
|
||||
}
|
||||
readRefusals.forget(sessionId)
|
||||
return open
|
||||
}
|
||||
@@ -145,10 +135,6 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
reason: 'recordMissing'
|
||||
})
|
||||
}
|
||||
// Restore may have opened it while this waited.
|
||||
if (session.journal.importPending) {
|
||||
await whenImported(sessionId, session)
|
||||
}
|
||||
readRefusals.forget(sessionId)
|
||||
return session
|
||||
})
|
||||
|
||||
+1
-7
@@ -43,8 +43,6 @@ export type StructuredAgentSessionConversationOpenDeps = {
|
||||
* what the gone generation left running itself, from what it read before. */
|
||||
export type StructuredAgentSessionConversationOpenOptions = {
|
||||
acquisition?: boolean
|
||||
/** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */
|
||||
deferPerSessionImport?: boolean
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionConversationOpenContext = {
|
||||
@@ -90,11 +88,7 @@ export async function openStructuredAgentSessionConversationJournal(
|
||||
expectedRuntimeFence: fence
|
||||
})
|
||||
const identity = journalIdentityFor(record, params)
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity,
|
||||
database: deps.journalDatabase,
|
||||
deferPerSessionImport: options.deferPerSessionImport
|
||||
})
|
||||
const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase })
|
||||
try {
|
||||
// A handed-over row found here is only doubt, which provider history decides under a won lease.
|
||||
await journal.markPendingSubmissionsUnknown(fence)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ export class StructuredAgentSessionSinkQueue {
|
||||
outcome = runNow(() => operation.run(bound))
|
||||
} else {
|
||||
this.waitingPublications.set(key, operation)
|
||||
// At handover, unless writes still wait behind an owed import; then at its place in line, so
|
||||
// At handover, unless a write is running or writes wait in line; then at its place in it, so
|
||||
// it never announces ahead of the writes issued before it.
|
||||
outcome = runNow(() =>
|
||||
bound.journal.readInOrder(() => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
|
||||
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support'
|
||||
import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { testEventSinkLogging } from './structured-agent-session-logger-test-support'
|
||||
@@ -120,41 +119,6 @@ function target(
|
||||
}
|
||||
}
|
||||
|
||||
/** A real journal whose copy from an earlier build's per-chat file is still owed: its first write
|
||||
* pays it, and every write issued meanwhile waits in its queue. The one real backlog a chat has. */
|
||||
async function owingTarget(fence: number) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-owed-'))
|
||||
const { journal, history } = await openJournalOwingImport({
|
||||
stateDirectory: root,
|
||||
identity: JOURNAL_IDENTITY
|
||||
})
|
||||
const published: (AgentSessionTurnActivity | null | undefined)[] = []
|
||||
const bound: StructuredAgentSessionEventTarget = {
|
||||
journal,
|
||||
fence,
|
||||
publish: (activity) => published.push(activity)
|
||||
}
|
||||
/** The row each of these ordinals created, in journal order. */
|
||||
const landed = (): number[] =>
|
||||
journal.snapshot().items.flatMap((item) => {
|
||||
const ordinal = [0, 1, 2, 3, 4].find(
|
||||
(index) => item.itemId === agentJournalItemKey(identity(index))
|
||||
)
|
||||
return ordinal === undefined ? [] : [ordinal]
|
||||
})
|
||||
return {
|
||||
bound,
|
||||
journal,
|
||||
history: history.length,
|
||||
published,
|
||||
landed,
|
||||
dispose: async () => {
|
||||
await journal.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('deferred structured agent-session event sink', () => {
|
||||
it('buffers writes made before the journal exists and drains them in arrival order', async () => {
|
||||
const log: Recorded[] = []
|
||||
@@ -261,29 +225,6 @@ describe('deferred structured agent-session event sink', () => {
|
||||
expect(log).toEqual([{ call: 'appendItem', fence: 1, ordinal: 0 }])
|
||||
})
|
||||
|
||||
it('lands writes already handed to the journal when closed; only never-bound ones drop', async () => {
|
||||
const owed = await owingTarget(3)
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
|
||||
deferred.bind(owed.bound)
|
||||
|
||||
deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
deferred.sink.appendItem(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
// Handed over, waiting behind the copy: none has landed when the sink closes.
|
||||
expect(owed.landed()).toEqual([])
|
||||
const written = deferred.sink.written?.()
|
||||
deferred.close()
|
||||
expect(
|
||||
deferred.sink.tryAppendItem?.(identity(3), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
).toEqual({ accepted: false, reason: 'closed' })
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(owed.landed()).toEqual([0, 1, 2])
|
||||
await expect(written).resolves.toEqual({ ok: true })
|
||||
expect(owed.journal.cursor().sequence).toBe(owed.history + 3)
|
||||
await owed.dispose()
|
||||
})
|
||||
|
||||
it('reports one refused append, fails the barrier, and stops later writes', async () => {
|
||||
const log: Recorded[] = []
|
||||
const errors: unknown[] = []
|
||||
@@ -376,47 +317,6 @@ describe('deferred structured agent-session event sink', () => {
|
||||
expect(log).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('pauses and resumes the reader at the same watermarks over writes the journal has not landed', async () => {
|
||||
const changes: boolean[] = []
|
||||
const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() }
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink({
|
||||
...testEventSinkLogging(),
|
||||
watermarks: {
|
||||
pauseQueuedBytes: 1_000_000,
|
||||
maxQueuedBytes: 1_000_000,
|
||||
lowQueuedBytes: 1_000_000,
|
||||
pauseQueuedOperations: 3,
|
||||
maxQueuedOperations: 4,
|
||||
lowQueuedOperations: 1
|
||||
},
|
||||
readingControl,
|
||||
onBackpressureChange: (paused) => changes.push(paused)
|
||||
})
|
||||
const owed = await owingTarget(5)
|
||||
deferred.bind(owed.bound)
|
||||
const append = (ordinal: number) =>
|
||||
deferred.sink.tryAppendItem?.(identity(ordinal), BODY, {
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
|
||||
expect([append(0), append(1)]).toEqual([{ accepted: true }, { accepted: true }])
|
||||
expect(readingControl.pauseReading).not.toHaveBeenCalled()
|
||||
expect(append(2)).toEqual({ accepted: true })
|
||||
expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 3 })
|
||||
expect(readingControl.pauseReading).toHaveBeenCalledOnce()
|
||||
expect(append(3)).toEqual({ accepted: true })
|
||||
expect(append(4)).toEqual({ accepted: false, reason: 'backpressure' })
|
||||
|
||||
// Nothing has landed yet: every admitted write waits behind the copy it owes.
|
||||
expect(owed.landed()).toEqual([])
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
expect(changes).toEqual([true, false])
|
||||
expect(readingControl.resumeReading).toHaveBeenCalledOnce()
|
||||
expect(deferred.state()).toMatchObject({ backpressured: false, queuedOperations: 0 })
|
||||
expect(owed.landed()).toEqual([0, 1, 2, 3])
|
||||
await owed.dispose()
|
||||
})
|
||||
|
||||
it('admits a resolved append and publication as one bounded operation', async () => {
|
||||
const log: Recorded[] = []
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink({
|
||||
@@ -654,28 +554,6 @@ describe('deferred structured agent-session event sink', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('skips a publication handed over and then replaced, and runs its replacement after the write between them', async () => {
|
||||
const owed = await owingTarget(6)
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
|
||||
deferred.bind(owed.bound)
|
||||
const seen: string[] = []
|
||||
owed.bound.publish = (activity) =>
|
||||
seen.push(`${activity?.text ?? 'none'} over ${owed.landed().join(',')}`)
|
||||
|
||||
deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' })
|
||||
deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
|
||||
deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' })
|
||||
// The first publication was handed over and waits behind the copy; the second replaced it.
|
||||
expect(deferred.state().queuedOperations).toBe(3)
|
||||
expect(seen).toEqual([])
|
||||
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
expect(seen).toEqual(['Checking the result over 0,1'])
|
||||
expect(deferred.state().queuedOperations).toBe(0)
|
||||
await owed.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('producer linkage reaches the journal through every append path', () => {
|
||||
|
||||
@@ -229,10 +229,8 @@ export class StructuredAgentSessionHost {
|
||||
showSessionTabs = this.tabs.showSessionTabs
|
||||
setSessionTabVisibility = this.tabs.setSessionTabVisibility
|
||||
notifySessionTabHidden = this.tabs.notifySessionTabHidden
|
||||
/** The records file could not be read this launch, so chats it holds are not listed yet. */
|
||||
legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true
|
||||
/** This runtime holds a chat: a record, or the records file's chats still owed their copy. */
|
||||
holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed()
|
||||
/** This runtime holds a chat record, readable or not. */
|
||||
holdsSessions = (): boolean => this.deps.store.holdsRecords()
|
||||
onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener)
|
||||
|
||||
reconcileRestartLeases = (): Promise<void> => this.restore.reconcileRestartLeases()
|
||||
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
// A chat whose per-chat file did not read back as its copy is refused as history that cannot be
|
||||
// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong.
|
||||
|
||||
import { cp, rm } from 'node:fs/promises'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import {
|
||||
JournalImportMismatchError,
|
||||
journalOpenRefusalError
|
||||
} from '../agent-session-journal/journal-open-failure'
|
||||
import { replayJournal } from '../agent-session-journal/journal-open'
|
||||
import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import {
|
||||
adapter,
|
||||
attach,
|
||||
CALLER,
|
||||
envelope,
|
||||
hostTestState,
|
||||
replaceHostTestState
|
||||
} from './structured-agent-session-host-test-harness'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestMessage
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
|
||||
const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } }))
|
||||
|
||||
vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof PerSessionImport>()
|
||||
return {
|
||||
...actual,
|
||||
// What the copy's verify throws for a file that does not read back as copied.
|
||||
importPerSessionJournal: async (
|
||||
input: Parameters<typeof actual.importPerSessionJournal>[0]
|
||||
) => {
|
||||
if (!copy.mismatched) {
|
||||
return actual.importPerSessionJournal(input)
|
||||
}
|
||||
throw journalOpenRefusalError(
|
||||
new JournalImportMismatchError(
|
||||
`per-chat journal of ${input.identity.sessionId} read back short`
|
||||
)
|
||||
)
|
||||
},
|
||||
// Restore lists a chat still in its per-chat file from a fold of it, and owes the copy.
|
||||
previewPerSessionJournal: async (
|
||||
input: Parameters<typeof actual.previewPerSessionJournal>[0]
|
||||
) =>
|
||||
copy.mismatched
|
||||
? replayJournal(input.database.db, input.identity.sessionId)
|
||||
: actual.previewPerSessionJournal(input)
|
||||
}
|
||||
})
|
||||
|
||||
const UNLOADABLE = {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_journal_unreadable',
|
||||
message: 'Unable to load this chat.',
|
||||
details: { reason: 'journalCorrupt' }
|
||||
}
|
||||
}
|
||||
|
||||
const relaunchedRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
copy.mismatched = false
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(
|
||||
relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */
|
||||
async function relaunchWithMismatchedCopy(): Promise<StructuredAgentSessionHost> {
|
||||
const before = hostTestState()
|
||||
await attach()
|
||||
await before.host.flushStreamedEvents(SESSION)
|
||||
await before.store.renewLeases([])
|
||||
const relaunched = `${before.root}-relaunched`
|
||||
relaunchedRoots.push(relaunched)
|
||||
// A dead process holds no lock.
|
||||
await cp(before.root, relaunched, {
|
||||
recursive: true,
|
||||
filter: (source) => !source.includes('.lock')
|
||||
})
|
||||
const store = await openTestAgentSessionRecordStore(relaunched)
|
||||
const host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
store,
|
||||
adapter: adapter(),
|
||||
journalDatabase: openTestJournalHostDatabase(relaunched),
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-next',
|
||||
now: () => NOW
|
||||
})
|
||||
replaceHostTestState({ store, host })
|
||||
copy.mismatched = true
|
||||
return host
|
||||
}
|
||||
|
||||
function send(host: StructuredAgentSessionHost) {
|
||||
const body = hostTestMessage('sent to a chat that did not copy')
|
||||
return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
||||
}
|
||||
|
||||
it('refuses a send and a Stop that open the chat as unloadable', async () => {
|
||||
const host = await relaunchWithMismatchedCopy()
|
||||
|
||||
await expect(send(host)).resolves.toMatchObject(UNLOADABLE)
|
||||
await expect(
|
||||
host.cancel(CALLER, {
|
||||
envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }),
|
||||
turnId: 'turn-1'
|
||||
})
|
||||
).resolves.toMatchObject(UNLOADABLE)
|
||||
await host.flushAllStreamedEvents()
|
||||
})
|
||||
|
||||
// Startup listed the chat from its per-chat file; the send's write pays the copy it owes.
|
||||
it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => {
|
||||
const host = await relaunchWithMismatchedCopy()
|
||||
await host.restoreReadableSessions([SESSION])
|
||||
expect(host.hasSession(SESSION)).toBe(true)
|
||||
|
||||
await expect(send(host)).resolves.toMatchObject(UNLOADABLE)
|
||||
await host.flushAllStreamedEvents()
|
||||
})
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
// A mutation reads the fold after every write issued before it, even while a restore's import is
|
||||
// owed: the open pays the import first, so provider rows queued behind it have landed. A failed
|
||||
// import is reported and never refuses the mutation.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import {
|
||||
attach,
|
||||
CALLER,
|
||||
envelope,
|
||||
hostTestState
|
||||
} from './structured-agent-session-host-test-harness'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition'
|
||||
|
||||
let host: StructuredAgentSessionHost
|
||||
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
|
||||
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
|
||||
let warned: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
;({ host, acquire, cancelTurn } = hostTestState())
|
||||
warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function journal(): AgentSessionJournal {
|
||||
const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal
|
||||
if (!open) {
|
||||
throw new Error('no open journal')
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
function providerEvents(): StructuredAgentSessionEventSink {
|
||||
const events = acquire.mock.calls.at(-1)?.[0].events
|
||||
if (!events) {
|
||||
throw new Error('no provider bound')
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Owed work standing in for a restore's import: every write waits behind it until it settles. */
|
||||
function oweImport(): PromiseWithResolvers<void> {
|
||||
const owed = Promise.withResolvers<void>()
|
||||
journal()['queue'].owe(() => owed.promise)
|
||||
return owed
|
||||
}
|
||||
|
||||
/** A turn the provider opened by itself, its row queued behind the owed import. */
|
||||
function providerOpensTurn(turnId: string, ordinal: number): void {
|
||||
providerEvents().appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId, ordinal },
|
||||
{ kind: 'turn', turnId, state: 'running' },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
}
|
||||
|
||||
function stopNamingNoTurn() {
|
||||
return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) })
|
||||
}
|
||||
|
||||
describe('a mutation while an import is owed', () => {
|
||||
it('a Stop naming no turn sees the turn the provider opened, and interrupts it', async () => {
|
||||
await attach()
|
||||
const owed = oweImport()
|
||||
providerOpensTurn('turn-p', 900)
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
|
||||
const stopping = stopNamingNoTurn()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
// It waits for the import, as a reader does, then reads the turn.
|
||||
expect(cancelTurn).not.toHaveBeenCalled()
|
||||
owed.resolve()
|
||||
|
||||
expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } })
|
||||
expect(cancelTurn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => {
|
||||
await attach()
|
||||
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
|
||||
Object.assign(host.deps.adapter, {
|
||||
changeThreadGoal,
|
||||
capabilities: () => CODEX_STRUCTURED_AGENT.capabilities
|
||||
})
|
||||
const owed = oweImport()
|
||||
providerOpensTurn('turn-g', 901)
|
||||
providerEvents().appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-g', ordinal: 902 },
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Goal',
|
||||
threadGoal: {
|
||||
state: 'set',
|
||||
goal: {
|
||||
objective: 'Earlier goal',
|
||||
status: 'active',
|
||||
tokenBudget: null,
|
||||
tokensUsed: 1,
|
||||
timeUsedSeconds: 2,
|
||||
createdAt: 3_000,
|
||||
updatedAt: 4_000
|
||||
}
|
||||
}
|
||||
},
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
|
||||
const change = { kind: 'set' as const, objective: 'New goal' }
|
||||
const setting = host.changeThreadGoal(CALLER, {
|
||||
envelope: envelope('agentSession.threadGoal', { change }),
|
||||
change
|
||||
})
|
||||
// Long enough for the goal set to reach its read before the import lands.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(changeThreadGoal).not.toHaveBeenCalled()
|
||||
owed.resolve()
|
||||
|
||||
expect(await setting).toMatchObject({ ok: true })
|
||||
expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true }))
|
||||
const objective = journal()
|
||||
.snapshot()
|
||||
.items.find((item) => item.body.kind === 'message' && item.body.sentAs === 'goal')
|
||||
expect(objective?.turnScope).toEqual({
|
||||
kind: 'turn',
|
||||
turnItemId: expect.stringContaining(':turn-g:')
|
||||
})
|
||||
})
|
||||
|
||||
it('a failed import is reported and the Stop still answers', async () => {
|
||||
await attach()
|
||||
const owed = oweImport()
|
||||
owed.reject(new Error('disk I/O error'))
|
||||
|
||||
expect(await stopNamingNoTurn()).toMatchObject({ ok: true })
|
||||
expect(warned).toHaveBeenCalledWith(
|
||||
'[agent-session] open-for-write: the import owed before a write failed',
|
||||
expect.objectContaining({ error: new Error('disk I/O error') })
|
||||
)
|
||||
})
|
||||
})
|
||||
-46
@@ -516,50 +516,4 @@ describe('a failed Stop', () => {
|
||||
}
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
|
||||
// The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands.
|
||||
it('keeps its pause when it fails while its writes wait behind owed work', async () => {
|
||||
const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 }
|
||||
const turnRow = (state: 'running' | 'completed') => {
|
||||
rig
|
||||
.providerEvents()
|
||||
.appendItem(
|
||||
turn,
|
||||
{ kind: 'turn', turnId: 'turn-w', state, startedAt: 1 },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
}
|
||||
const working = await rig.workingSend()
|
||||
turnRow('running')
|
||||
await rig.settleAccepted(working, 'w')
|
||||
const draftId = await queuedDraft('paused by stop')
|
||||
// The interrupt ends the turn, so the drain runs as soon as the lane frees.
|
||||
vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => {
|
||||
turnRow('completed')
|
||||
return { cancelled: true }
|
||||
})
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('no open journal')
|
||||
}
|
||||
const append = journal.appendItem.bind(journal)
|
||||
vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) =>
|
||||
body.kind === 'status'
|
||||
? Promise.reject(new Error('disk full'))
|
||||
: append(identity, body, options)
|
||||
)
|
||||
// Owed from the Stop's first write, after the open paid any import: its writes wait behind it.
|
||||
const owed = Promise.withResolvers<void>()
|
||||
const withdraw = journal.rejectQueuedSubmissions.bind(journal)
|
||||
vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => {
|
||||
journal['queue'].owe(() => owed.promise)
|
||||
return withdraw(...args)
|
||||
})
|
||||
const stopping = rig.stop()
|
||||
await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce())
|
||||
owed.resolve()
|
||||
await expect(stopping).rejects.toThrow('disk full')
|
||||
expect(journal.activeTurnId()).toBeNull()
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
})
|
||||
|
||||
+20
-43
@@ -1,22 +1,18 @@
|
||||
// Read restore decides whether a session comes back at all.
|
||||
//
|
||||
// A chat still in the pre-SQLite format has no `journal.db`, so the probe that
|
||||
// loads one reports nothing. Reading that as "no session" is what removed these
|
||||
// chats: an unpublished session is also what prunes its tab out of the saved
|
||||
// workspace, so the tab is gone before anything can explain itself.
|
||||
// Read restore decides whether a session comes back at all: only one with a record and a journal
|
||||
// to read is published, and publishing it starts no agent.
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table'
|
||||
import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
@@ -62,16 +58,6 @@ const openDeps = () => ({
|
||||
})
|
||||
const opened: AgentSessionJournal[] = []
|
||||
|
||||
async function writeRemnant(name: string): Promise<string> {
|
||||
const dir = journalDirectoryFor(journalRoot, {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionId: SESSION_ID
|
||||
})
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8')
|
||||
return join(dir, name)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-'))
|
||||
})
|
||||
@@ -82,40 +68,31 @@ afterEach(async () => {
|
||||
await rm(journalRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('a session whose journal is still the pre-SQLite format', () => {
|
||||
it('is published, carrying the message that explains it', async () => {
|
||||
const transcript = await writeRemnant('log.jsonl')
|
||||
describe('read restore', () => {
|
||||
it('publishes a session with a journal, starting no agent', async () => {
|
||||
const deps = openDeps()
|
||||
const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID)
|
||||
expect(restored).toBeNull()
|
||||
publishJournalSessionEpoch(
|
||||
deps.journalDatabase.db,
|
||||
{ sessionId: SESSION_ID, workspaceId: WORKSPACE_ID },
|
||||
'epoch-1'
|
||||
)
|
||||
|
||||
const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID)
|
||||
const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID)
|
||||
|
||||
expect(restored).not.toBeNull()
|
||||
opened.push(restored!.session.journal)
|
||||
const disclosed = restored!.session.journal
|
||||
.snapshot()
|
||||
.items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : ''))
|
||||
expect(disclosed.join('')).toContain(transcript)
|
||||
// Publishing it costs no agent process; acquisition still waits for the user.
|
||||
expect(restored!.session.child).toBeNull()
|
||||
expect(published).not.toBeNull()
|
||||
opened.push(published!.session.journal)
|
||||
expect(published!.session.child).toBeNull()
|
||||
})
|
||||
|
||||
it('is published for a remnant whose log is gone', async () => {
|
||||
await writeRemnant('snapshot.json')
|
||||
|
||||
const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID)
|
||||
|
||||
expect(restored).not.toBeNull()
|
||||
opened.push(restored!.session.journal)
|
||||
})
|
||||
|
||||
it('still drops a session with neither a journal nor a remnant', async () => {
|
||||
it('drops a session with no journal', async () => {
|
||||
const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID)
|
||||
|
||||
expect(restored).toBeNull()
|
||||
})
|
||||
|
||||
it('still drops a session with no record', async () => {
|
||||
await writeRemnant('log.jsonl')
|
||||
|
||||
it('drops a session with no record', async () => {
|
||||
const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session')
|
||||
|
||||
expect(restored).toBeNull()
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant'
|
||||
import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths'
|
||||
import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table'
|
||||
import {
|
||||
openStructuredAgentSessionConversationJournal,
|
||||
@@ -11,7 +8,7 @@ import {
|
||||
/**
|
||||
* A reader's open: the conversation's own open, for a session that has a journal to read. One
|
||||
* with none — never written, or gone — stays unpublished rather than founding an empty one.
|
||||
* Opening can still write: the crash boundary, and the row explaining an old-format history.
|
||||
* Opening can still write: the crash boundary.
|
||||
*/
|
||||
export async function restoreStructuredAgentSessionRead(
|
||||
deps: StructuredAgentSessionConversationOpenDeps,
|
||||
@@ -21,22 +18,8 @@ export async function restoreStructuredAgentSessionRead(
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const database = deps.journalDatabase
|
||||
if (readJournalSessionEpoch(database.db, sessionId) === null) {
|
||||
// Not in the host's database yet: its history may still sit in a per-chat file the open
|
||||
// imports, or in the pre-SQLite format the open explains.
|
||||
const legacyDirectory = database.legacyDirectoryFor({
|
||||
workspaceId: record.location.workspaceId,
|
||||
sessionId
|
||||
})
|
||||
if (
|
||||
!existsSync(legacyJournalDatabaseFile(legacyDirectory)) &&
|
||||
!findJournalFileFormatRemnant(legacyDirectory)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) {
|
||||
return null
|
||||
}
|
||||
return openStructuredAgentSessionConversationJournal(deps, record, {
|
||||
deferPerSessionImport: true
|
||||
})
|
||||
return openStructuredAgentSessionConversationJournal(deps, record)
|
||||
}
|
||||
|
||||
+3
-5
@@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
const CALLER = { callerKey: 'client-1' }
|
||||
const METHODS = ['agentSession.setOption', 'agentSession.send'] as const
|
||||
@@ -352,12 +353,9 @@ describe('agentSessionRefusalOperationState host oracle', () => {
|
||||
|
||||
const unreadable = await createHarness()
|
||||
await unreadable.host.close(SESSION, 'evict')
|
||||
// The journal's open asks where the chat's per-chat file lives before it reads anything.
|
||||
const unreadableOpen = vi
|
||||
.spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('journal path unreadable')
|
||||
})
|
||||
.spyOn(AgentSessionJournal.prototype, 'open')
|
||||
.mockRejectedValue(new Error('journal path unreadable'))
|
||||
const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() }
|
||||
record(
|
||||
await assertHostAgreement(
|
||||
|
||||
+12
-7
@@ -9,7 +9,11 @@ import type {
|
||||
AgentJournalItemIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type StructuredAgentSessionEventTarget,
|
||||
@@ -37,10 +41,8 @@ let journal: AgentSessionJournal
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-resolved-append-'))
|
||||
// Its copy owed, so every write handed over waits in the queue: a resolver that read at submit
|
||||
// would read the row before the revisions ahead of it had landed.
|
||||
;({ journal } = await openJournalOwingImport({
|
||||
stateDirectory: root,
|
||||
journal = await openAgentSessionJournal({
|
||||
database: openTestJournalHostDatabase(root),
|
||||
identity: {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'workspace-1',
|
||||
@@ -48,11 +50,12 @@ beforeEach(async () => {
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journal.close()
|
||||
closeTestJournalHostDatabases()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -90,7 +93,9 @@ describe('resolved revisions', () => {
|
||||
}
|
||||
// Three revisions of one row stay three operations; none replaces another.
|
||||
expect(deferred.state().queuedOperations).toBe(3)
|
||||
deferred.bind(journalTarget())
|
||||
// Bound from inside a running read, so every revision waits in line: one resolved at
|
||||
// handover would read the row before the revisions ahead of it had landed.
|
||||
await journal.readInOrder(() => deferred.bind(journalTarget()))
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
expect(rowText()).toBe('abc')
|
||||
})
|
||||
|
||||
-610
@@ -1,610 +0,0 @@
|
||||
// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that
|
||||
// file, and copies nothing: the copy waits for the chat's first real read or write, which is
|
||||
// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when
|
||||
// every chat had its own file, and opens no file it does not restore.
|
||||
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
|
||||
import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request'
|
||||
import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import type * as SyncDatabaseModule from '../../sqlite/sync-database'
|
||||
import {
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase,
|
||||
readTestJournalRows
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import {
|
||||
journalDirectoryFor,
|
||||
legacyJournalDatabaseFile
|
||||
} from '../agent-session-journal/journal-paths'
|
||||
import {
|
||||
readJournalSessionEpoch,
|
||||
type JournalStoredRow
|
||||
} from '../agent-session-journal/journal-row-table'
|
||||
import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import'
|
||||
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
|
||||
import { journalIdentityFor } from './structured-agent-session-attach'
|
||||
import { attachParamsForRecord } from './structured-agent-session-conversation-open'
|
||||
import { StructuredAgentSessionConversations } from './structured-agent-session-conversations'
|
||||
import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime'
|
||||
import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime'
|
||||
import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal'
|
||||
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source'
|
||||
|
||||
const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({
|
||||
readOnlyOpens: new Array<string>(),
|
||||
openReadOnly: new Set<object>()
|
||||
}))
|
||||
|
||||
// Every read-only open is of a per-chat file: the host's own database opens read-write.
|
||||
vi.mock('../../sqlite/sync-database', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SyncDatabaseModule>()
|
||||
class RecordingDatabase extends actual.default {
|
||||
constructor(...args: ConstructorParameters<typeof actual.default>) {
|
||||
super(...args)
|
||||
if (args[1]?.readonly) {
|
||||
readOnlyOpens.push(String(args[0]))
|
||||
openReadOnly.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
override close(): void {
|
||||
openReadOnly.delete(this)
|
||||
super.close()
|
||||
}
|
||||
}
|
||||
return { ...actual, default: RecordingDatabase }
|
||||
})
|
||||
|
||||
const WORKSPACE_ID = 'repo-1::/tmp/workspace'
|
||||
const PROMPT = 'add a retry'
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
|
||||
function recordFor(sessionId: string): AgentSessionRecord {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease.
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
sessionId,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: 'codex',
|
||||
providerHandleChain: [
|
||||
{
|
||||
linkId: `codex-1-${sessionId}`,
|
||||
handle: codexProviderHandle(`thread-${sessionId}`),
|
||||
origin: 'created',
|
||||
mintedAtFence: 1,
|
||||
observedAt: 1
|
||||
}
|
||||
],
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 }
|
||||
} as unknown as AgentSessionRecord
|
||||
}
|
||||
|
||||
function identityFor(sessionId: string) {
|
||||
const record = recordFor(sessionId)
|
||||
return journalIdentityFor(
|
||||
record,
|
||||
attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 })
|
||||
)
|
||||
}
|
||||
|
||||
function legacyDirFor(sessionId: string): string {
|
||||
return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId })
|
||||
}
|
||||
|
||||
/** What the run before the upgrade left open in a chat, for its restore to settle. */
|
||||
type MidWork = 'running tool call' | 'unresolved send' | 'never handed over'
|
||||
|
||||
/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */
|
||||
async function seedLegacyChat(
|
||||
sessionId: string,
|
||||
replies = 1,
|
||||
midWork?: MidWork
|
||||
): Promise<JournalStoredRow[]> {
|
||||
const scratch = join(root, `scratch-${sessionId}`)
|
||||
const identity = identityFor(sessionId)
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity,
|
||||
database: openTestJournalHostDatabase(scratch),
|
||||
now: () => (clock += 1)
|
||||
})
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: `client-${sessionId}`,
|
||||
payloadFingerprint: 'fp-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] },
|
||||
fence: 1,
|
||||
handoverRecorded: true,
|
||||
// A person's send that run accepted and quit before handing over.
|
||||
...(midWork === 'never handed over'
|
||||
? { origin: 'client' as const, source: USER_MESSAGE_SOURCE }
|
||||
: {})
|
||||
})
|
||||
if (midWork === 'running tool call') {
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 },
|
||||
{ kind: 'tool-call', name: 'Read', input: {}, state: 'running' },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
}
|
||||
if (midWork === 'never handed over') {
|
||||
await journal.close()
|
||||
return writeLegacyChat(sessionId, scratch, journal.epoch)
|
||||
}
|
||||
await journal.resolveDispatch(
|
||||
midWork === 'unresolved send'
|
||||
? // Handed over, and never answered.
|
||||
{
|
||||
clientMessageId: `client-${sessionId}`,
|
||||
fence: 1,
|
||||
state: 'pending',
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
: {
|
||||
clientMessageId: `client-${sessionId}`,
|
||||
fence: 1,
|
||||
state: 'accepted',
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: `thread-${sessionId}`,
|
||||
turnId: 't',
|
||||
ordinal: 0
|
||||
}
|
||||
}
|
||||
)
|
||||
for (let ordinal = 1; ordinal <= replies; ordinal += 1) {
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal },
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
}
|
||||
await journal.close()
|
||||
return writeLegacyChat(sessionId, scratch, journal.epoch)
|
||||
}
|
||||
|
||||
/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */
|
||||
async function writeLegacyChat(
|
||||
sessionId: string,
|
||||
scratch: string,
|
||||
epoch: string
|
||||
): Promise<JournalStoredRow[]> {
|
||||
const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch)
|
||||
const path = legacyJournalDatabaseFile(legacyDirFor(sessionId))
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const db = new Database(path)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq));
|
||||
CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL,
|
||||
content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`)
|
||||
db.pragma('user_version = 2')
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
for (const row of rows) {
|
||||
insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson)
|
||||
}
|
||||
db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function legacyFile(sessionId: string): string {
|
||||
return legacyJournalDatabaseFile(legacyDirFor(sessionId))
|
||||
}
|
||||
|
||||
function hostDb(): Database.Database {
|
||||
return openTestJournalHostDatabase(root).db
|
||||
}
|
||||
|
||||
function importCount(): number {
|
||||
return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n)
|
||||
}
|
||||
|
||||
type LifetimeHost = Parameters<typeof createStructuredAgentSessionConversationLifetime>[0]
|
||||
|
||||
function conversations(): StructuredAgentSessionConversations {
|
||||
return new StructuredAgentSessionConversations({
|
||||
deliver: () => undefined,
|
||||
logger: recordingStructuredAgentSessionLogger().logger,
|
||||
now: () => clock
|
||||
})
|
||||
}
|
||||
|
||||
async function restore(sessionIds: readonly string[]) {
|
||||
const sessions = conversations()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`.
|
||||
const store = {
|
||||
getRecord: (sessionId: string) => recordFor(sessionId),
|
||||
listRecords: () => sessionIds.map(recordFor)
|
||||
} as unknown as AgentSessionRecordStore
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const deps = {
|
||||
store,
|
||||
adapter: {},
|
||||
journalDatabase: openTestJournalHostDatabase(root),
|
||||
logger: log.logger
|
||||
}
|
||||
await restoreStructuredAgentSessionsOnRestart({
|
||||
openDeps: deps,
|
||||
records: sessionIds.map(recordFor),
|
||||
reconcile: async () => true,
|
||||
resolveRecovery: async () => true,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
hasSession: (sessionId) => sessions.has(sessionId),
|
||||
onReadable: (sessionId, opened) => {
|
||||
sessions.set(sessionId, opened.session)
|
||||
}
|
||||
})
|
||||
const context = (): StructuredAgentSessionLifetimeContext =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`.
|
||||
({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext
|
||||
const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) =>
|
||||
createStructuredAgentSessionConversationLifetime({
|
||||
context,
|
||||
sessions: listed,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
open,
|
||||
deliveryActive: () => false,
|
||||
closeStatus: () => undefined,
|
||||
readChildWork: () => undefined
|
||||
})
|
||||
const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null)
|
||||
return { sessions, lifetime, lifetimeOver, log }
|
||||
}
|
||||
|
||||
function texts(items: readonly { body: unknown }[]): string {
|
||||
return JSON.stringify(items.map((entry) => entry.body))
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-'))
|
||||
clock = 1_000
|
||||
readOnlyOpens.length = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
closeTestJournalHostDatabases()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('startup restore of chats still in their per-chat files', () => {
|
||||
it('lists every restored chat without copying one, and opens only their files', async () => {
|
||||
const restored = ['chat-a', 'chat-b', 'chat-c']
|
||||
const rows = new Map<string, JournalStoredRow[]>()
|
||||
for (const sessionId of [...restored, 'chat-closed']) {
|
||||
rows.set(sessionId, await seedLegacyChat(sessionId))
|
||||
}
|
||||
readOnlyOpens.length = 0
|
||||
const before = new Map(
|
||||
await Promise.all(
|
||||
restored.map(
|
||||
async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const { sessions } = await restore(restored)
|
||||
|
||||
// Each file was closed by the fold that read it, so nothing holds it open for a later rename.
|
||||
expect(openReadOnly.size).toBe(0)
|
||||
// Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files.
|
||||
for (const sessionId of restored) {
|
||||
expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true)
|
||||
const entries = await readdir(legacyDirFor(sessionId))
|
||||
expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([])
|
||||
}
|
||||
|
||||
expect([...sessions.keys()].sort()).toEqual(restored)
|
||||
for (const sessionId of restored) {
|
||||
const journal = sessions.get(sessionId)!.journal
|
||||
expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length)
|
||||
expect(texts(journal.snapshot().items)).toContain('reply 1')
|
||||
expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull()
|
||||
expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true)
|
||||
}
|
||||
for (const sessionId of restored) {
|
||||
await sessions.get(sessionId)!.journal.close()
|
||||
}
|
||||
expect(importCount()).toBe(0)
|
||||
// One read of each restored chat's file, and none of the chat that was not restored.
|
||||
expect(readOnlyOpens.sort()).toEqual(
|
||||
restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort()
|
||||
)
|
||||
})
|
||||
|
||||
// Restore copies a chat only to write to it itself, settling what the last run left open (a
|
||||
// turn, tool call, approval, question, send or subagent). A settled chat is never copied here.
|
||||
it.each(['running tool call', 'unresolved send', 'never handed over'] as const)(
|
||||
'copies during restore only a chat it settles (%s)',
|
||||
async (midWork) => {
|
||||
const rows = await seedLegacyChat('chat-mid-work', 1, midWork)
|
||||
await seedLegacyChat('chat-settled')
|
||||
|
||||
const { sessions } = await restore(['chat-mid-work', 'chat-settled'])
|
||||
|
||||
// Restore wrote a settlement to the chat left mid-work, so it copied that chat first.
|
||||
const settled = sessions.get('chat-mid-work')!.journal
|
||||
expect(settled.cursor().sequence).toBeGreaterThan(rows.length)
|
||||
expect(
|
||||
readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length)
|
||||
).toEqual(rows)
|
||||
expect(existsSync(legacyFile('chat-mid-work'))).toBe(false)
|
||||
expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull()
|
||||
expect(existsSync(legacyFile('chat-settled'))).toBe(true)
|
||||
expect(importCount()).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
// A send the last run never handed over is kept as a card in the same database the copy wrote,
|
||||
// after the copy: the card and the rejected send both land behind the chat's own rows.
|
||||
it('keeps a send the last run never handed over as a card, after the copy', async () => {
|
||||
const rows = await seedLegacyChat('chat-kept', 0, 'never handed over')
|
||||
|
||||
const { sessions } = await restore(['chat-kept'])
|
||||
|
||||
const journal = sessions.get('chat-kept')!.journal
|
||||
await journal.whenImported()
|
||||
const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch)
|
||||
expect(copied.slice(0, rows.length)).toEqual(rows)
|
||||
expect(copied).toHaveLength(rows.length + 1)
|
||||
expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({
|
||||
kind: 'dispatch',
|
||||
clientMessageId: 'client-chat-kept',
|
||||
state: 'rejected'
|
||||
})
|
||||
const cards = hostDb()
|
||||
.prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?')
|
||||
.all('chat-kept')
|
||||
expect(cards).toEqual([
|
||||
{ message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lets other work run while it reads a large per-chat file', async () => {
|
||||
// Past one batch of the file's rows.
|
||||
const rows = await seedLegacyChat('chat-a', 520)
|
||||
let turns = 0
|
||||
let ticking = true
|
||||
const tick = (): void => {
|
||||
turns += 1
|
||||
if (ticking) {
|
||||
setImmediate(tick)
|
||||
}
|
||||
}
|
||||
|
||||
setImmediate(tick)
|
||||
const folded = await previewPerSessionJournal({
|
||||
database: openTestJournalHostDatabase(root),
|
||||
identity: identityFor('chat-a'),
|
||||
legacyDirectory: legacyDirFor('chat-a')
|
||||
})
|
||||
ticking = false
|
||||
|
||||
expect(turns).toBeGreaterThan(0)
|
||||
expect(folded?.state.lastSequence).toBe(rows.length)
|
||||
expect(openReadOnly.size).toBe(0)
|
||||
})
|
||||
|
||||
// T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands.
|
||||
it('offers the restorable turn of a chat it did not copy', async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor.
|
||||
const marker = {
|
||||
sessionId: 'chat-a',
|
||||
journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length }
|
||||
} as AgentSessionResumeMarker
|
||||
|
||||
const { sessions } = await restore(['chat-a'])
|
||||
const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
sessions,
|
||||
now: () => clock,
|
||||
enqueue: (operation) => operation()
|
||||
})
|
||||
|
||||
expect(withdrawal.movedOn(marker)).toBe(false)
|
||||
expect(
|
||||
latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items)
|
||||
).toBe(PROMPT)
|
||||
expect(importCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('copies a chat on its first read, and serves its history verbatim', async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
const { lifetime, sessions } = await restore(['chat-a'])
|
||||
const listed = sessions.get('chat-a')!.journal.snapshot()
|
||||
|
||||
const journal = (await lifetime.conversation('chat-a')).journal
|
||||
const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 })
|
||||
|
||||
// The copied chat is the chat restore listed, item for item.
|
||||
expect(journal.snapshot()).toEqual(listed)
|
||||
|
||||
expect(importCount()).toBe(1)
|
||||
expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows)
|
||||
expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq))
|
||||
expect(existsSync(legacyDirFor('chat-a'))).toBe(false)
|
||||
})
|
||||
|
||||
// A read queued behind restore's open of the same chat reaches it through its own open, not the
|
||||
// listing: it still waits for the copy, and reads the chat from the one database.
|
||||
it('makes a read whose open lands on the chat restore opened wait for its copy', async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
const { sessions, lifetimeOver } = await restore(['chat-a'])
|
||||
const restored = sessions.get('chat-a')!
|
||||
const lifetime = lifetimeOver(conversations(), async () => restored)
|
||||
|
||||
const { journal } = await lifetime.conversation('chat-a')
|
||||
const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 })
|
||||
|
||||
expect(importCount()).toBe(1)
|
||||
expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq))
|
||||
})
|
||||
|
||||
// Whether the read finds the chat restore listed, or opens it and lands on the one restore opened.
|
||||
it.each(['listed', 'opened'] as const)(
|
||||
'refuses a read of the chat restore %s when its copy meets damage, never with the storage text',
|
||||
async (reach) => {
|
||||
await seedLegacyChat('chat-a')
|
||||
const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a'])
|
||||
const restored = sessions.get('chat-a')!
|
||||
await rm(legacyDirFor('chat-a'), { recursive: true, force: true })
|
||||
await mkdir(legacyDirFor('chat-a'), { recursive: true })
|
||||
await writeFile(legacyFile('chat-a'), 'not a database, and never was one')
|
||||
const reader =
|
||||
reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored)
|
||||
|
||||
const read = reader.conversation('chat-a')
|
||||
|
||||
await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError)
|
||||
await expect(read).rejects.toMatchObject({
|
||||
message: 'agent_session_journal_unreadable',
|
||||
refusal: { details: { reason: 'journalCorrupt' } }
|
||||
})
|
||||
// The storage text the reader never sees goes to the host's log.
|
||||
expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([
|
||||
expect.objectContaining({
|
||||
fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) }
|
||||
})
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('copies a chat before its first write, and the write lands after its history', async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
const { sessions } = await restore(['chat-a'])
|
||||
const journal = sessions.get('chat-a')!.journal
|
||||
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 },
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
|
||||
const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)
|
||||
expect(stored.slice(0, rows.length)).toEqual(rows)
|
||||
expect(stored).toHaveLength(rows.length + 1)
|
||||
expect(importCount()).toBe(1)
|
||||
})
|
||||
|
||||
it("copies a chat before a Stop's event, which lands before the turn's end and the kill after it", async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
const { sessions } = await restore(['chat-a'])
|
||||
const journal = sessions.get('chat-a')!.journal
|
||||
expect(journal.importPending).toBe(true)
|
||||
const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
|
||||
// In the Stop's order: its event, then the stopped turn's end, then a row the kill writes.
|
||||
const [stopped, ended, killed] = await Promise.all([
|
||||
journal.appendStopEvent({ reason: 'user-stop' }, 1),
|
||||
journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 },
|
||||
{ kind: 'turn', turnId: 't', state: 'interrupted', startedAt: 1 },
|
||||
scope
|
||||
),
|
||||
journal.appendItem(
|
||||
{ provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 10 },
|
||||
{ kind: 'status', text: 'the agent ended' },
|
||||
scope
|
||||
)
|
||||
])
|
||||
|
||||
expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch).slice(0, rows.length)).toEqual(
|
||||
rows
|
||||
)
|
||||
expect([stopped.sequence, ended.cursor.sequence, killed.cursor.sequence]).toEqual([
|
||||
rows.length + 1,
|
||||
rows.length + 2,
|
||||
rows.length + 3
|
||||
])
|
||||
})
|
||||
|
||||
it("copies a chat before its first queued message, which lands as that chat's draft", async () => {
|
||||
const rows = await seedLegacyChat('chat-a')
|
||||
const { sessions } = await restore(['chat-a'])
|
||||
const journal = sessions.get('chat-a')!.journal
|
||||
expect(journal.importPending).toBe(true)
|
||||
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: 'draft-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] },
|
||||
fingerprint: 'fp-draft-1',
|
||||
hostInstance: 'proc-1'
|
||||
})
|
||||
|
||||
expect(journal.importPending).toBe(false)
|
||||
expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows)
|
||||
expect(existsSync(legacyFile('chat-a'))).toBe(false)
|
||||
expect(importCount()).toBe(1)
|
||||
expect(journal.queuedMessages.list()).toMatchObject([
|
||||
{ messageId: 'draft-1', state: 'waiting' }
|
||||
])
|
||||
})
|
||||
|
||||
it('never shows a read racing the copy a partly copied chat', async () => {
|
||||
// Past one import batch, so the copy yields to other work between batches.
|
||||
const rows = await seedLegacyChat('chat-a', 520)
|
||||
const { lifetime, sessions } = await restore(['chat-a'])
|
||||
const journal = sessions.get('chat-a')!.journal
|
||||
const cursor = { epoch: rows[0]!.epoch, sequence: 0 }
|
||||
const seen: { published: boolean; copied: number; folded: number }[] = []
|
||||
const reads: Promise<number>[] = []
|
||||
let ticking = true
|
||||
const tick = (): void => {
|
||||
seen.push({
|
||||
published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null,
|
||||
copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n),
|
||||
folded: journal.cursor().sequence
|
||||
})
|
||||
reads.push(
|
||||
lifetime.conversation('chat-a').then(({ journal: read }) => {
|
||||
const since = read.readSince(cursor)
|
||||
return since.ok ? since.rows.length : -1
|
||||
})
|
||||
)
|
||||
if (ticking) {
|
||||
setImmediate(tick)
|
||||
}
|
||||
}
|
||||
setImmediate(tick)
|
||||
|
||||
await lifetime.conversation('chat-a')
|
||||
ticking = false
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// The copy was under way while other work ran, and none of it saw part of the chat.
|
||||
expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true)
|
||||
expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true)
|
||||
expect(seen.every((turn) => turn.folded === rows.length)).toBe(true)
|
||||
expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length]))
|
||||
})
|
||||
})
|
||||
+1
-13
@@ -63,8 +63,7 @@ export function structuredAgentSessionSendBlock(
|
||||
return null
|
||||
}
|
||||
|
||||
/** The conversation a send or a Stop writes to, opened when this host holds it closed. Once it
|
||||
* answers, every write issued before it has settled, so a mutation reads a whole fold. */
|
||||
/** The conversation a send or a Stop writes to, opened when this host holds it closed. */
|
||||
export async function openConversationForWrite(
|
||||
openConversation: (sessionId: string) => Promise<StructuredAgentSessionHostSession | null>,
|
||||
envelope: AgentSessionMutationEnvelope,
|
||||
@@ -84,17 +83,6 @@ export async function openConversationForWrite(
|
||||
if (!session) {
|
||||
return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED }
|
||||
}
|
||||
// Writes wait behind a restore's owed import. A failed import settled them too (they failed
|
||||
// with it), so it is reported and never refuses the mutation: its own writes fail as they would.
|
||||
if (session.journal.importPending) {
|
||||
await session.journal.whenImported().catch((error: unknown) => {
|
||||
logger.warn('the import owed before a write failed', {
|
||||
scope: 'open-for-write',
|
||||
sessionId: envelope.sessionId,
|
||||
error
|
||||
})
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -26,6 +26,7 @@ import {
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
|
||||
@@ -135,10 +136,9 @@ function startAgent(): Promise<unknown> {
|
||||
describe('settled attach retry', () => {
|
||||
it('settles a post-acquisition journal failure and retries without a restart', async () => {
|
||||
const journalDatabase = openTestJournalHostDatabase(root)
|
||||
// The journal's open asks where the chat's per-chat file lives before it reads anything.
|
||||
vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => {
|
||||
throw new Error('journal path unavailable')
|
||||
})
|
||||
vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce(
|
||||
new Error('journal path unavailable')
|
||||
)
|
||||
host = new StructuredAgentSessionHost({
|
||||
agents: NO_STRUCTURED_AGENTS,
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
|
||||
+1
-73
@@ -1,6 +1,5 @@
|
||||
// A Stop's interrupt never waits on the journal. Its withdrawal and its Stop event are bookkeeping:
|
||||
// one that throws is reported and the Stop still interrupts. Its writes may wait behind owed work;
|
||||
// they are issued, and the interrupt goes out before they land.
|
||||
// one that throws is reported and the Stop still interrupts.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
@@ -216,75 +215,4 @@ describe.each([
|
||||
await vi.waitFor(() => expectReported(failed))
|
||||
}
|
||||
)
|
||||
|
||||
// The open pays an import owed before the Stop, so the work falls owed at the Stop's first write:
|
||||
// the one moment the Stop's own writes can wait behind it.
|
||||
it('interrupts before owed work its writes wait behind is paid', async () => {
|
||||
const journal = await runningTurn()
|
||||
const owed = Promise.withResolvers<void>()
|
||||
const withdraw = journal.rejectQueuedSubmissions.bind(journal)
|
||||
vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => {
|
||||
journal['queue'].owe(() => owed.promise)
|
||||
return withdraw(...args)
|
||||
})
|
||||
let answered = false
|
||||
const stopping = stop(fields).finally(() => {
|
||||
answered = true
|
||||
})
|
||||
try {
|
||||
await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce())
|
||||
expect(journal.importPending).toBe(true)
|
||||
expect(answered).toBe(false)
|
||||
} finally {
|
||||
owed.resolve()
|
||||
}
|
||||
expect(await stopping).toMatchObject({ ok: true })
|
||||
expect(journal.importPending).toBe(false)
|
||||
})
|
||||
|
||||
// Under owed work the event is issued before the interrupt but lands later, still in queue order:
|
||||
// ahead of the stopped turn's end, which the provider hands over as it takes the interrupt.
|
||||
it('issues its Stop event before the interrupt, and it lands ahead of the turn it ends', async () => {
|
||||
const journal = await runningTurn()
|
||||
const order: string[] = []
|
||||
const owed = Promise.withResolvers<void>()
|
||||
const withdraw = journal.rejectQueuedSubmissions.bind(journal)
|
||||
vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => {
|
||||
journal['queue'].owe(() => owed.promise)
|
||||
return withdraw(...args)
|
||||
})
|
||||
const appendStopEvent = journal.appendStopEvent.bind(journal)
|
||||
vi.spyOn(journal, 'appendStopEvent').mockImplementation((...args) => {
|
||||
order.push('event issued')
|
||||
return appendStopEvent(...args)
|
||||
})
|
||||
cancelTurn.mockImplementation(async () => {
|
||||
order.push('interrupt')
|
||||
acquire.mock.calls
|
||||
.at(-1)![0]
|
||||
.events!.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 },
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'interrupted' },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
return { cancelled: true }
|
||||
})
|
||||
|
||||
const stopping = stop(fields)
|
||||
try {
|
||||
await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce(), INTERRUPT_WAIT)
|
||||
} finally {
|
||||
owed.resolve()
|
||||
}
|
||||
expect(await stopping).toMatchObject({ ok: true })
|
||||
|
||||
expect(order).toEqual(['event issued', 'interrupt'])
|
||||
const since = journal.readSince({ epoch: journal.epoch, sequence: 0 })
|
||||
const rows = since.ok ? since.rows : []
|
||||
const stopRow = rows.find((row) => row.kind === 'tombstone' && row.stopEvent)
|
||||
const turnEnd = rows.find(
|
||||
(row) => row.kind === 'item' && row.body.kind === 'turn' && row.body.state === 'interrupted'
|
||||
)
|
||||
expect(stopRow?.seq).toBeLessThan(turnEnd?.seq ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,8 +55,7 @@ export async function performThreadGoalChange(
|
||||
let replacesGoal = false
|
||||
if (change.kind === 'set') {
|
||||
// Read before the objective row lands: that row is a message, not a goal transition. A goal
|
||||
// transition the host accepted is already in the fold: it landed at its call, or before the
|
||||
// open's owed import finished.
|
||||
// transition the host accepted is already in the fold: it landed at its call.
|
||||
replacesGoal = ctx.journal.threadGoal() !== null
|
||||
}
|
||||
// Journal first: an active goal starts provider work at once, and the objective
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
|
||||
import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer'
|
||||
import {
|
||||
@@ -87,36 +86,6 @@ async function rig(watermarks: Partial<StructuredAgentSessionSinkWatermarks> = {
|
||||
return { root, journal, deferred, sink: deferred.sink, publishes, bind }
|
||||
}
|
||||
|
||||
/** A bound sink over a chat whose copy into the host's database is still owed, so every write,
|
||||
* the transition's included, waits in the journal's queue until the first one pays it. */
|
||||
async function owedRig() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-'))
|
||||
roots.push(root)
|
||||
const { journal } = await openJournalOwingImport({
|
||||
stateDirectory: root,
|
||||
identity: {
|
||||
sessionId: SESSION,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'grok',
|
||||
providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' }
|
||||
},
|
||||
now: () => 1_000
|
||||
})
|
||||
const failures: unknown[] = []
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink({
|
||||
...testEventSinkLogging(SESSION),
|
||||
onFailed: (error) => failures.push(error)
|
||||
})
|
||||
deferred.bind({ journal, fence: 1, publish: () => undefined })
|
||||
const ownKeys = () =>
|
||||
journal
|
||||
.snapshot()
|
||||
.items.map((item) => item.itemId)
|
||||
.filter((itemId) => itemId.includes(SESSION))
|
||||
return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() }
|
||||
}
|
||||
|
||||
describe('structured agent-session transitions', () => {
|
||||
it('lands its steps back to back, each resolved after the one before it', async () => {
|
||||
const { journal, deferred, sink, publishes, bind } = await rig()
|
||||
@@ -409,68 +378,6 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`)
|
||||
['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId)))
|
||||
)
|
||||
})
|
||||
|
||||
it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => {
|
||||
const { journal, deferred, sink, ownKeys, close } = await owedRig()
|
||||
try {
|
||||
sink.tryAppendTransition?.({
|
||||
lifecycle: false,
|
||||
publish: false,
|
||||
steps: [
|
||||
itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })),
|
||||
itemStep((view) =>
|
||||
view.itemBody(agentJournalItemKey(identity('first')))
|
||||
? { identity: identity('second'), body: tool('read', 'running') }
|
||||
: null
|
||||
)
|
||||
]
|
||||
})
|
||||
sink.tryAppendItem?.(identity('later'), tool('later', 'running'), {
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
expect(journal.importPending).toBe(true)
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(journal.importPending).toBe(false)
|
||||
expect(ownKeys()).toEqual(
|
||||
['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId)))
|
||||
)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => {
|
||||
const { deferred, sink, failures, ownKeys, close } = await owedRig()
|
||||
try {
|
||||
const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') }))
|
||||
sink.tryAppendTransition?.({
|
||||
lifecycle: false,
|
||||
publish: false,
|
||||
steps: [
|
||||
itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })),
|
||||
itemStep(
|
||||
() => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }),
|
||||
64
|
||||
),
|
||||
itemStep(third)
|
||||
]
|
||||
})
|
||||
// Accepted before the failure is known: the sink's existing rule lets it land.
|
||||
sink.tryAppendItem?.(identity('later'), tool('later', 'running'), {
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
await expect(deferred.drained()).resolves.toMatchObject({ ok: false })
|
||||
|
||||
expect(ownKeys()).toEqual(
|
||||
['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId)))
|
||||
)
|
||||
expect(third).not.toHaveBeenCalled()
|
||||
expect(failures).toHaveLength(1)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolved lifecycle batches', () => {
|
||||
|
||||
@@ -33,8 +33,8 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session
|
||||
import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
/** Whether the fold reads working. Every write has landed by its call's return, and the open paid
|
||||
* any owed import, so a Stop reads it without waiting on the write queue. */
|
||||
/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it
|
||||
* without waiting on the write queue. */
|
||||
export function isMainAgentWorking(
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence'>
|
||||
): boolean {
|
||||
|
||||
@@ -161,7 +161,7 @@ export async function performSend(
|
||||
} catch (error) {
|
||||
// Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So
|
||||
// does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the
|
||||
// journal already classified (a copy that did not verify).
|
||||
// journal already classified (a failed transaction that will not roll back).
|
||||
if (
|
||||
isAgentSessionRefusalError(error) ||
|
||||
classifyJournalOpenFailure(error) === 'journalCorrupt' ||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { assertFence, withLease } from './agent-session-lease-transitions'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
/**
|
||||
* How the failed attempt's provider process was accounted for.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Importing the records file's backup, without minting a second writer. Only the one-time import
|
||||
// into the chat database (agent-session-legacy-record-import.ts) applies this now.
|
||||
//
|
||||
// The backup is the previous committed generation. The commit that never landed may have granted a
|
||||
// fence chosen by `nextAgentSessionFence` from the backup lease, and
|
||||
// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above
|
||||
// `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it.
|
||||
//
|
||||
// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence`
|
||||
// once per transaction, and the file's save path aborted rather than advancing the primary past a
|
||||
// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught.
|
||||
//
|
||||
// This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current
|
||||
// fence is what an earlier version did, and it corrupted exactly the records it meant to save: a
|
||||
// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by
|
||||
// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline —
|
||||
// made the record invalid, quarantined it on the next load, and dropped back to the same backup.
|
||||
//
|
||||
// Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart),
|
||||
// `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process
|
||||
// that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading
|
||||
// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider
|
||||
// session; the fence protects the store, not the provider session.
|
||||
|
||||
import { nextAgentSessionFence } from '../../shared/agent-session-next-fence'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
|
||||
export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void {
|
||||
for (const [sessionId, record] of state.records) {
|
||||
const floor = nextAgentSessionFence(record.lease) + 1
|
||||
if (!Number.isSafeInteger(floor)) {
|
||||
throw new Error('agent_session_fence_exhausted')
|
||||
}
|
||||
state.records.set(sessionId, {
|
||||
...record,
|
||||
lease: {
|
||||
...record.lease,
|
||||
minimumNextFence: floor
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Retired execution-claim keys. Split from the store on the same rule its ledger admission is:
|
||||
// the state transition lives here, the transaction stays in the store.
|
||||
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
/** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */
|
||||
export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { AgentSessionTabTable } from './agent-session-tab-table'
|
||||
import { foundAgentSessionRecord } from './agent-session-record-founding'
|
||||
import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { renewAgentSessionLease } from './agent-session-lease-transitions'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
export type AgentSessionLeaseRenewal = {
|
||||
sessionId: string
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
// The one-time copy of `agent-sessions.json` into the chat journal database: what it copies, what it
|
||||
// leaves untouched, and how each fence stays clear of anything the file's writers could have granted.
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { nextAgentSessionFence } from '../../shared/agent-session-next-fence'
|
||||
import {
|
||||
agentSessionOperationKey,
|
||||
type AgentSessionOperationRow
|
||||
} from '../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import {
|
||||
encodePersistedAgentSessionProviderHandle,
|
||||
isAgentSessionProviderHandle
|
||||
} from '../../shared/agent-session-provider-handle-encoding'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database'
|
||||
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { legacyAgentSessionStorePath } from './agent-session-record-store-file'
|
||||
import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open'
|
||||
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const ALPHA = 'session-alpha'
|
||||
const BETA = 'session-beta'
|
||||
const OPERATION_ID = `${NOW}-000000000000000000000000000000aa`
|
||||
const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}`
|
||||
|
||||
let root: string
|
||||
const opened: JournalHostDatabase[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
opened.splice(0).forEach((database) => database.close())
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const legacyPath = (): string => legacyAgentSessionStorePath(root)
|
||||
|
||||
/** A released chat: no owner a restart probe must look for. */
|
||||
function record(sessionId: string, lease: Partial<AgentSessionLease> = {}): AgentSessionRecord {
|
||||
return agentSessionRecordFixture(
|
||||
agentSessionLeaseFixture({
|
||||
sessionId,
|
||||
runtimeFence: 2,
|
||||
ownerProcess: null,
|
||||
reservedSpawnToken: null,
|
||||
claimStatus: 'released',
|
||||
...lease
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const OPERATION: AgentSessionOperationRow = {
|
||||
callerKey: 'client-1',
|
||||
operationId: OPERATION_ID,
|
||||
fingerprint: 'fp-1',
|
||||
operationTimestamp: NOW,
|
||||
recordedAt: NOW,
|
||||
expiresAt: NOW + 60_000,
|
||||
outcome: { status: 'succeeded', sessionId: ALPHA }
|
||||
}
|
||||
|
||||
/** The records file only ever held handles in their stored form. */
|
||||
function storedForm(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const chain = value.providerHandleChain
|
||||
return Array.isArray(chain)
|
||||
? {
|
||||
...value,
|
||||
providerHandleChain: chain.map((link: unknown) =>
|
||||
typeof link === 'object' &&
|
||||
link !== null &&
|
||||
'handle' in link &&
|
||||
isAgentSessionProviderHandle(link.handle)
|
||||
? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) }
|
||||
: link
|
||||
)
|
||||
}
|
||||
: value
|
||||
}
|
||||
|
||||
function legacyFile(
|
||||
records: readonly (Record<string, unknown> & { sessionId: string })[],
|
||||
extra: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
hostId: 'local',
|
||||
records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(value)])),
|
||||
operations: {},
|
||||
retiredClaimKeys: [],
|
||||
unusableRecords: {},
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLegacy(file: unknown, backup?: unknown): Promise<void> {
|
||||
await mkdir(dirname(legacyPath()), { recursive: true })
|
||||
await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file))
|
||||
if (backup !== undefined) {
|
||||
await writeFile(
|
||||
`${legacyPath()}.bak`,
|
||||
typeof backup === 'string' ? backup : JSON.stringify(backup)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What an install does: open the database, running the copy if it is owed, then the store. */
|
||||
async function install(): Promise<{
|
||||
database: JournalHostDatabase
|
||||
store: AgentSessionRecordStore
|
||||
reports: unknown[]
|
||||
}> {
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const database = await openStructuredAgentSessionJournalDatabase({
|
||||
logger: log.logger,
|
||||
stateDirectory: root,
|
||||
hostId: 'local'
|
||||
})
|
||||
opened.push(database)
|
||||
// Each report reaches the log as one entry under its scope, its kind as the outcome.
|
||||
const reports = log.entries
|
||||
.filter((entry) => entry.fields.scope === 'legacy-record-import')
|
||||
.map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest }))
|
||||
return {
|
||||
database,
|
||||
store: AgentSessionRecordStore.open({
|
||||
journalDatabase: database,
|
||||
hostId: 'local'
|
||||
}),
|
||||
reports
|
||||
}
|
||||
}
|
||||
|
||||
function unreconciled(value: AgentSessionRecord): AgentSessionRecord {
|
||||
return { ...value, lease: { ...value.lease, unreconciled: true } }
|
||||
}
|
||||
|
||||
describe('copying the records file into the chat database', () => {
|
||||
it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => {
|
||||
const alpha = record(ALPHA)
|
||||
const beta = record(BETA, { runtimeFence: 5 })
|
||||
const file = legacyFile([alpha, beta], {
|
||||
operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION },
|
||||
retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }],
|
||||
sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }]
|
||||
})
|
||||
await writeLegacy(file, file)
|
||||
const [primary, backup] = await Promise.all([
|
||||
readFile(legacyPath()),
|
||||
readFile(`${legacyPath()}.bak`)
|
||||
])
|
||||
|
||||
const { database, store, reports } = await install()
|
||||
|
||||
expect(reports).toEqual([])
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(4)
|
||||
expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha))
|
||||
expect(store.getRecord(BETA)).toEqual(unreconciled(beta))
|
||||
expect(store.listOperationRows()).toEqual([OPERATION])
|
||||
expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true)
|
||||
expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] })
|
||||
expect(store.getSessionTabId(BETA)).toBe('tab-beta')
|
||||
expect(await readFile(legacyPath())).toEqual(primary)
|
||||
expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup)
|
||||
})
|
||||
|
||||
it('copies once: a later launch never reads the file again', async () => {
|
||||
await writeLegacy(legacyFile([record(ALPHA)]))
|
||||
const first = await install()
|
||||
await first.store.setConversationName(ALPHA, 'kept')
|
||||
first.database.close()
|
||||
// A downgraded build changed its own file; the database keeps what it holds.
|
||||
await writeLegacy(legacyFile([record(BETA)]))
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
expect(store.getRecord(ALPHA)?.conversationName).toBe('kept')
|
||||
expect(store.getRecord(BETA)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a file no read will make usable, copies nothing, and leaves it untouched', async () => {
|
||||
const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 })
|
||||
await writeLegacy(adHoc)
|
||||
|
||||
const { database, store, reports } = await install()
|
||||
|
||||
expect(reports).toEqual([
|
||||
{
|
||||
kind: 'unusable',
|
||||
error: expect.objectContaining({ message: 'agent_session_store_corrupt' })
|
||||
}
|
||||
])
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(4)
|
||||
expect(store.listRecords()).toEqual([])
|
||||
expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc)
|
||||
})
|
||||
|
||||
it('reports a file from another host id and copies it, so its owners stay unresolved', async () => {
|
||||
await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' })
|
||||
|
||||
const { store, reports } = await install()
|
||||
|
||||
expect(reports).toEqual([
|
||||
{ kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' }
|
||||
])
|
||||
expect(store.hostId).toBe('local')
|
||||
expect(store.getRecord(ALPHA)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => {
|
||||
const legacy = {
|
||||
...record(ALPHA, {
|
||||
ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }
|
||||
}),
|
||||
lease: {
|
||||
...record(ALPHA).lease,
|
||||
ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' },
|
||||
claimStatus: 'live',
|
||||
runtimeKind: 'tui',
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: 'op-handoff',
|
||||
processlessAt: 5
|
||||
}
|
||||
}
|
||||
await writeLegacy(legacyFile([legacy]))
|
||||
|
||||
const { database, store } = await install()
|
||||
|
||||
expect(store.isSessionUnreadable(ALPHA)).toBe(false)
|
||||
const lease = store.getRecord(ALPHA)?.lease
|
||||
expect(lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
handoffStage: 'recovering',
|
||||
handoffOperationId: 'op-handoff',
|
||||
claimStatus: 'conflicted'
|
||||
})
|
||||
expect(lease).not.toHaveProperty('processlessAt')
|
||||
const stored = database.db
|
||||
.prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?')
|
||||
.get(ALPHA)
|
||||
expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({
|
||||
runtimeKind: 'native',
|
||||
handoffStage: 'recovering'
|
||||
})
|
||||
expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a record the file set aside', () => {
|
||||
it('copies a record this build cannot read verbatim, and never grants it', async () => {
|
||||
const unsupported = { ...record(ALPHA), schemaVersion: 1 }
|
||||
await writeLegacy(legacyFile([unsupported]))
|
||||
|
||||
const { database, store } = await install()
|
||||
|
||||
expect(store.getRecord(ALPHA)).toBeNull()
|
||||
expect(store.isSessionUnreadable(ALPHA)).toBe(true)
|
||||
await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow(
|
||||
'execution_owner_reconciling'
|
||||
)
|
||||
const stored = database.db
|
||||
.prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?')
|
||||
.get(ALPHA)
|
||||
expect(JSON.parse(String(stored?.record_json))).toEqual(storedForm(unsupported))
|
||||
})
|
||||
|
||||
// #23589: both copies exist only when an older build recovered the readable one from its backup,
|
||||
// so the set-aside copy may already have granted fences the readable one cannot show.
|
||||
it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => {
|
||||
const readable = record(ALPHA, { runtimeFence: 2 })
|
||||
const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 }
|
||||
await writeLegacy(
|
||||
legacyFile([readable], {
|
||||
unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } }
|
||||
})
|
||||
)
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
const lease = store.getRecord(ALPHA)?.lease
|
||||
expect(lease?.runtimeFence).toBe(2)
|
||||
expect(lease?.minimumNextFence).toBe(8)
|
||||
expect(nextAgentSessionFence(lease!)).toBe(8)
|
||||
})
|
||||
|
||||
it('takes a record the primary set aside from the backup, with its floor raised', async () => {
|
||||
const readable = record(ALPHA, { runtimeFence: 3 })
|
||||
await writeLegacy(
|
||||
legacyFile([], {
|
||||
unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } }
|
||||
}),
|
||||
legacyFile([readable])
|
||||
)
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
// The commit the backup lost may have granted 4, so the next grant clears it.
|
||||
expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a primary the backup stands in for', () => {
|
||||
it('copies the backup with every floor raised past what the lost commit could have granted', async () => {
|
||||
await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })]))
|
||||
|
||||
const { store, reports } = await install()
|
||||
|
||||
expect(reports).toEqual([])
|
||||
expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 })
|
||||
expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated')
|
||||
})
|
||||
|
||||
it('reports both copies unusable and copies nothing', async () => {
|
||||
await writeLegacy('{ truncated', '{ also truncated')
|
||||
|
||||
const { store, reports } = await install()
|
||||
|
||||
expect(reports).toMatchObject([{ kind: 'unusable' }])
|
||||
expect(store.listRecords()).toEqual([])
|
||||
})
|
||||
|
||||
// A read that can clear says nothing about the backup's contents, so the copy stays owed.
|
||||
it('keeps the copy owed when the backup cannot be read behind a torn primary', async () => {
|
||||
await writeLegacy('{ truncated')
|
||||
await mkdir(`${legacyPath()}.bak`)
|
||||
|
||||
const { database, store, reports } = await install()
|
||||
|
||||
expect(reports).toMatchObject([{ kind: 'unavailable' }])
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(3)
|
||||
expect(store.listRecords()).toEqual([])
|
||||
})
|
||||
|
||||
// Falling back would replace the primary's newer state with the backup's older one for good.
|
||||
it('never takes a valid backup when the primary cannot be read', async () => {
|
||||
await mkdir(legacyPath(), { recursive: true })
|
||||
await writeFile(`${legacyPath()}.bak`, JSON.stringify(legacyFile([record(ALPHA)])))
|
||||
|
||||
const { database, store, reports } = await install()
|
||||
|
||||
expect(reports).toMatchObject([{ kind: 'unavailable' }])
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(3)
|
||||
expect(store.listRecords()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the tab index from before the table', () => {
|
||||
it('seeds the table from the visible list, keeping each chat on the id it has today', async () => {
|
||||
const gamma = 'session-gamma'
|
||||
await writeLegacy(
|
||||
legacyFile(
|
||||
[
|
||||
{ ...record(ALPHA), surfaceTabId: 'tab-alpha' },
|
||||
{ ...record(BETA), surfaceTabId: `structured-agent-session-${BETA}` },
|
||||
record(gamma)
|
||||
],
|
||||
{ visibleSessionIds: [ALPHA, BETA] }
|
||||
)
|
||||
)
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
expect(store.getSessionTabId(ALPHA)).toBe('tab-alpha')
|
||||
expect(store.getSessionTabId(BETA)).toBe(`structured-agent-session-${BETA}`)
|
||||
expect(store.getSessionTabId(gamma)).toBeNull()
|
||||
expect(store.listVisibleSessionIds()).toEqual([ALPHA, BETA])
|
||||
})
|
||||
|
||||
it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => {
|
||||
const cleared = (replacementSessionId: string) => ({
|
||||
command: 'clear',
|
||||
state: 'completed',
|
||||
phase: 'committed',
|
||||
operationId: OPERATION_ID,
|
||||
callerKey: 'client-1',
|
||||
replacementSessionId
|
||||
})
|
||||
await writeLegacy(
|
||||
legacyFile(
|
||||
[
|
||||
{ ...record(ALPHA), conversationCommand: cleared('clear-one') },
|
||||
{ ...record('clear-one'), conversationCommand: cleared('clear-two') },
|
||||
{ ...record('clear-two'), surfaceTabId: 'structured-agent-session-clear-two' }
|
||||
],
|
||||
{ visibleSessionIds: [ALPHA, 'clear-two'] }
|
||||
)
|
||||
)
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
expect(store.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID)
|
||||
const reopenedTab = store.getSessionTabId(ALPHA)
|
||||
expect(reopenedTab).not.toBe(LEGACY_TAB_ID)
|
||||
expect(reopenedTab).not.toContain(':')
|
||||
expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two'])
|
||||
})
|
||||
|
||||
it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => {
|
||||
await mkdir(legacyPath(), { recursive: true })
|
||||
const owed = await install()
|
||||
owed.database.db
|
||||
.prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)')
|
||||
.run(BETA, JSON.stringify(storedForm(record(BETA))))
|
||||
await AgentSessionRecordStore.open({
|
||||
journalDatabase: owed.database,
|
||||
hostId: 'local'
|
||||
}).setSessionTabVisibility(BETA, true)
|
||||
owed.database.close()
|
||||
await rm(legacyPath(), { recursive: true })
|
||||
await writeLegacy(legacyFile([record(ALPHA)]))
|
||||
|
||||
const { database, store } = await install()
|
||||
|
||||
expect(journalPragmaNumber(database.db, 'user_version')).toBe(4)
|
||||
expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA])
|
||||
// Restore then takes the profile's tabs, beside the tab the owed-era chat left.
|
||||
expect(store.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [BETA] })
|
||||
})
|
||||
|
||||
it('reads a recorded table, never the record field', async () => {
|
||||
await writeLegacy(
|
||||
legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], {
|
||||
sessionTabs: [{ tabId: 'tab-alpha', sessionId: ALPHA }]
|
||||
})
|
||||
)
|
||||
|
||||
expect((await install()).store.getSessionTabId(ALPHA)).toBe('tab-alpha')
|
||||
})
|
||||
|
||||
it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => {
|
||||
await writeLegacy(
|
||||
legacyFile([{ ...record(ALPHA), surfaceTabId: `agent-session:${ALPHA}` }], {
|
||||
visibleSessionIds: [ALPHA]
|
||||
})
|
||||
)
|
||||
|
||||
const { store } = await install()
|
||||
|
||||
expect(store.isSessionUnreadable(ALPHA)).toBe(false)
|
||||
expect(store.getSessionTabId(ALPHA)).toBe(LEGACY_TAB_ID)
|
||||
})
|
||||
|
||||
it('treats a malformed table as an unusable file rather than guessing', async () => {
|
||||
await writeLegacy(
|
||||
legacyFile([record(ALPHA)], {
|
||||
sessionTabs: [
|
||||
{ tabId: 'tab-alpha', sessionId: ALPHA },
|
||||
{ tabId: 'tab-alpha', sessionId: BETA }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const { store, reports } = await install()
|
||||
|
||||
expect(reports).toMatchObject([{ kind: 'unusable' }])
|
||||
expect(store.listRecords()).toEqual([])
|
||||
expect(store.getVisibleSessionTabIndex().present).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4
|
||||
// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that
|
||||
// still uses them finds them as it left them.
|
||||
//
|
||||
// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the
|
||||
// migration completes: the file is left untouched, but a later repair of it is never imported. A
|
||||
// read that can clear leaves the copy owed, and every later launch retries it until one reads the
|
||||
// file, finds it absent or unusable, or the import is retired.
|
||||
|
||||
import { nextAgentSessionFence } from '../../shared/agent-session-next-fence'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form'
|
||||
import {
|
||||
NO_LEGACY_JOURNAL_RECORDS,
|
||||
type JournalLegacyRecordImport
|
||||
} from '../native-chat/agent-session-journal/journal-database'
|
||||
import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence'
|
||||
import {
|
||||
legacyAgentSessionStorePath,
|
||||
loadAgentSessionStore,
|
||||
type AgentSessionStoreState
|
||||
} from './agent-session-record-store-file'
|
||||
import {
|
||||
insertAgentSessionStoreRowsIfAbsent,
|
||||
withoutRetiredLeaseLatches,
|
||||
type AgentSessionStoreImportRows
|
||||
} from './agent-session-record-rows'
|
||||
import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules'
|
||||
|
||||
export type LegacyAgentSessionRecords =
|
||||
| { kind: 'absent' }
|
||||
/** The primary parsed, or it was unusable and `.bak` parsed (`fromBackup`). */
|
||||
| { kind: 'loaded'; state: AgentSessionStoreState; fromBackup: boolean }
|
||||
/** Present, and neither the primary nor `.bak` parses: no read will change that. */
|
||||
| { kind: 'unusable'; error: unknown }
|
||||
/** Reading the primary or `.bak` failed in a way that can clear (EACCES, EIO, EMFILE). */
|
||||
| { kind: 'unavailable'; error: unknown }
|
||||
|
||||
/** Why the copy found nothing to take, or took less than the file holds. */
|
||||
export type LegacyAgentSessionRecordImportReport =
|
||||
| { kind: 'unusable' | 'unavailable'; error: unknown }
|
||||
| { kind: 'host-mismatch'; fileHostId: string; hostId: string }
|
||||
|
||||
function readFailedTransiently(error: unknown): boolean {
|
||||
const cause = error instanceof Error ? error.cause : undefined
|
||||
return (
|
||||
!(error instanceof Error && error.message === 'agent_session_store_corrupt') ||
|
||||
(typeof cause === 'object' && cause !== null && 'code' in cause && cause.code !== 'ENOENT')
|
||||
)
|
||||
}
|
||||
|
||||
/** Never throws. */
|
||||
export async function readLegacyAgentSessionRecords(
|
||||
stateDirectory: string,
|
||||
hostId: string
|
||||
): Promise<LegacyAgentSessionRecords> {
|
||||
try {
|
||||
const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId)
|
||||
return loaded.storeFound
|
||||
? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup }
|
||||
: { kind: 'absent' }
|
||||
} catch (error) {
|
||||
return readFailedTransiently(error)
|
||||
? { kind: 'unavailable', error }
|
||||
: { kind: 'unusable', error }
|
||||
}
|
||||
}
|
||||
|
||||
function safeFence(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A readable record whose id also has a set-aside copy: an older build recovered the readable one
|
||||
* from its backup and could not read the newer copy, which may already have granted fences the
|
||||
* readable one cannot show. The next grant must clear both.
|
||||
*/
|
||||
function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord {
|
||||
let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1)
|
||||
const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined
|
||||
if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) {
|
||||
const runtimeFence = safeFence(rawLease.runtimeFence)
|
||||
const minimumNextFence =
|
||||
'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined
|
||||
? safeFence(rawLease.minimumNextFence)
|
||||
: undefined
|
||||
if (runtimeFence !== null && minimumNextFence !== null) {
|
||||
floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence }))
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(floor)) {
|
||||
throw new Error('agent_session_fence_exhausted')
|
||||
}
|
||||
return { ...record, lease: { ...record.lease, minimumNextFence: floor } }
|
||||
}
|
||||
|
||||
function importRows(
|
||||
state: AgentSessionStoreState,
|
||||
fromBackup: boolean
|
||||
): AgentSessionStoreImportRows {
|
||||
const records = new Map(state.records)
|
||||
if (fromBackup) {
|
||||
// The commit the backup lost may have granted a fence it cannot show: main's rule for this state.
|
||||
raiseAgentSessionFencesAfterBackupRecovery({ ...state, records })
|
||||
}
|
||||
const rows: AgentSessionStoreImportRows['records'] = []
|
||||
for (const [sessionId, loaded] of records) {
|
||||
const setAside = state.unreadableRecords.get(sessionId)
|
||||
const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded
|
||||
const json = JSON.stringify(
|
||||
encodeAgentSessionRecord({ ...record, lease: withoutRetiredLeaseLatches(record.lease) })
|
||||
)
|
||||
// A record the load rules refuse is kept as its bytes, which every load then sets aside.
|
||||
rows.push([
|
||||
sessionId,
|
||||
isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json))
|
||||
? json
|
||||
: JSON.stringify(encodeAgentSessionRecord(loaded))
|
||||
])
|
||||
}
|
||||
for (const [sessionId, { raw }] of state.unreadableRecords) {
|
||||
if (!records.has(sessionId)) {
|
||||
// Verbatim: a build that can read it gets it back, and this one derives it as unreadable.
|
||||
rows.push([sessionId, JSON.stringify(raw ?? null)])
|
||||
}
|
||||
}
|
||||
return {
|
||||
records: rows,
|
||||
operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]),
|
||||
retiredClaimKeys: state.retiredClaimKeys,
|
||||
sessionTabs:
|
||||
state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/** What the migration runs for `legacy`, each failure reported once here. */
|
||||
export function legacyAgentSessionRecordImport(
|
||||
legacy: LegacyAgentSessionRecords,
|
||||
hostId: string,
|
||||
report: (report: LegacyAgentSessionRecordImportReport) => void
|
||||
): JournalLegacyRecordImport {
|
||||
switch (legacy.kind) {
|
||||
case 'absent':
|
||||
return NO_LEGACY_JOURNAL_RECORDS
|
||||
case 'unavailable':
|
||||
report(legacy)
|
||||
return { owed: true }
|
||||
case 'unusable':
|
||||
report(legacy)
|
||||
return NO_LEGACY_JOURNAL_RECORDS
|
||||
case 'loaded':
|
||||
break
|
||||
}
|
||||
let rows: AgentSessionStoreImportRows
|
||||
try {
|
||||
rows = importRows(legacy.state, legacy.fromBackup)
|
||||
} catch (error) {
|
||||
report({ kind: 'unusable', error })
|
||||
return NO_LEGACY_JOURNAL_RECORDS
|
||||
}
|
||||
if (legacy.state.hostId !== hostId) {
|
||||
// Owners it names answer `unresolved` against this host, the conservative verdict.
|
||||
report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId })
|
||||
}
|
||||
return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) }
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
export type AgentSessionOperationAdmission = {
|
||||
callerKey: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The agent-session store's rows in the host's chat journal database: every row loaded once at open,
|
||||
// exactly the rows a transaction changed written back, and the one-time copy of the records file.
|
||||
// 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.
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form'
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import type { SqliteRow } from '../sqlite/sqlite-statement'
|
||||
import {
|
||||
AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
type AgentSessionStoreState
|
||||
} from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import type { AgentSessionStoreRowWrites } from './agent-session-store-draft'
|
||||
import {
|
||||
isReadableAgentSessionStoreOperation,
|
||||
@@ -81,13 +78,8 @@ function unreadableRecordReason(value: unknown): string {
|
||||
* 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,
|
||||
hostId: string
|
||||
): AgentSessionStoreState {
|
||||
export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionStoreState {
|
||||
const state: AgentSessionStoreState = {
|
||||
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
hostId,
|
||||
records: new Map(),
|
||||
operations: new Map(),
|
||||
retiredClaimKeys: [],
|
||||
@@ -163,8 +155,6 @@ export function loadAgentSessionStoreRows(
|
||||
}
|
||||
if (recorded) {
|
||||
state.sessionTabs = table
|
||||
} else if (table.sessionIds().length > 0) {
|
||||
state.unrecordedSessionTabs = table
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -224,56 +214,3 @@ export function writeAgentSessionStoreRows(
|
||||
writeTabIndex(db, writes.sessionTabs)
|
||||
}
|
||||
}
|
||||
|
||||
/** What the one-time import copies in, each row already serialized. */
|
||||
export type AgentSessionStoreImportRows = {
|
||||
records: [sessionId: string, json: string][]
|
||||
operations: [key: string, json: string][]
|
||||
retiredClaimKeys: { keyId: string; retiredAt: number }[]
|
||||
/** Null when the file never recorded a tab index. */
|
||||
sessionTabs: PersistedAgentSessionTab[] | null
|
||||
}
|
||||
|
||||
/** Inserts each row whose key the database does not hold yet: a chat created while the import was
|
||||
* owed keeps its own rows, and ids never collide with the file's. */
|
||||
export function insertAgentSessionStoreRowsIfAbsent(
|
||||
db: Database.Database,
|
||||
rows: AgentSessionStoreImportRows
|
||||
): void {
|
||||
const insertRecord = db.prepare(
|
||||
'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)'
|
||||
)
|
||||
for (const [sessionId, json] of rows.records) {
|
||||
insertRecord.run(sessionId, json)
|
||||
}
|
||||
const insertOperation = db.prepare(
|
||||
'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)'
|
||||
)
|
||||
for (const [key, json] of rows.operations) {
|
||||
insertOperation.run(key, json)
|
||||
}
|
||||
const insertKey = db.prepare(
|
||||
'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)'
|
||||
)
|
||||
for (const { keyId, retiredAt } of rows.retiredClaimKeys) {
|
||||
insertKey.run(keyId, retiredAt)
|
||||
}
|
||||
const tabs = rows.sessionTabs
|
||||
if (tabs) {
|
||||
const insertTab = db.prepare(
|
||||
'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)'
|
||||
)
|
||||
// Ahead of any tab a chat created while the import was owed recorded: these were open first.
|
||||
tabs.forEach(({ tabId, sessionId }, position) =>
|
||||
insertTab.run(tabId, sessionId, position - tabs.length)
|
||||
)
|
||||
db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run(
|
||||
SESSION_TABS_RECORDED,
|
||||
'1'
|
||||
)
|
||||
} else {
|
||||
// An index a chat created while the import was owed recorded holds none of the file's chats:
|
||||
// "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them.
|
||||
db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* The records file the agent-session store kept before it moved into the chat journal database,
|
||||
* read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written.
|
||||
* Its `.bak` fallback and salvage survive here for that one read, until the import is retired.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
agentSessionOperationKey,
|
||||
isAgentSessionOperationRow,
|
||||
type AgentSessionOperationRow
|
||||
} from '../../shared/agent-session-operation-ledger'
|
||||
import {
|
||||
AGENT_SESSION_RECORD_SCHEMA_VERSION,
|
||||
isPersistedAgentSessionRecord,
|
||||
type AgentSessionRecord
|
||||
} from '../../shared/agent-session-record'
|
||||
import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form'
|
||||
import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table'
|
||||
|
||||
export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const
|
||||
|
||||
/** In a host's state directory, beside the journal database, where the records file lived. */
|
||||
export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions'
|
||||
|
||||
export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json'
|
||||
|
||||
export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number }
|
||||
|
||||
export type AgentSessionStoreState = {
|
||||
schemaVersion: number
|
||||
hostId: string
|
||||
records: Map<string, AgentSessionRecord>
|
||||
operations: Map<string, AgentSessionOperationRow>
|
||||
retiredClaimKeys: RetiredAgentSessionClaimKey[]
|
||||
/** Rows this build cannot validate, kept with a durable refusal reason. */
|
||||
unreadableRecords: Map<string, { reason: string; raw: unknown }>
|
||||
/** Chat tab id → the conversation it shows; null until this store first records a tab. */
|
||||
sessionTabs: AgentSessionTabTable | null
|
||||
/** Tab rows an index never recorded holds (chats opened while the import was owed). */
|
||||
unrecordedSessionTabs?: AgentSessionTabTable
|
||||
}
|
||||
|
||||
export type LoadedAgentSessionStore = {
|
||||
state: AgentSessionStoreState
|
||||
storeFound: boolean
|
||||
/** True when the file was written by a newer schema; this host reads but never writes it. */
|
||||
readOnly: boolean
|
||||
/** True when the primary file was unusable and the previous committed copy was used. */
|
||||
recoveredFromBackup: boolean
|
||||
}
|
||||
|
||||
export function agentSessionStorePath(directory: string): string {
|
||||
return join(directory, AGENT_SESSION_STORE_FILE_NAME)
|
||||
}
|
||||
|
||||
/** The records file of the host whose state directory this is. */
|
||||
export function legacyAgentSessionStorePath(stateDirectory: string): string {
|
||||
return agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME))
|
||||
}
|
||||
|
||||
const backupPath = (filePath: string): string => `${filePath}.bak`
|
||||
|
||||
function emptyState(hostId: string): AgentSessionStoreState {
|
||||
return {
|
||||
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
hostId,
|
||||
records: new Map(),
|
||||
operations: new Map(),
|
||||
retiredClaimKeys: [],
|
||||
unreadableRecords: new Map(),
|
||||
sessionTabs: null
|
||||
}
|
||||
}
|
||||
|
||||
function parseState(raw: string, hostId: string): Pick<LoadedAgentSessionStore, 'state'> | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return null
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is read back as `unknown` and validated below before use.
|
||||
const file = parsed as {
|
||||
schemaVersion?: unknown
|
||||
hostId?: unknown
|
||||
records?: unknown
|
||||
operations?: unknown
|
||||
retiredClaimKeys?: unknown
|
||||
unusableRecords?: unknown
|
||||
sessionTabs?: unknown
|
||||
visibleSessionIds?: unknown
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(file.schemaVersion) ||
|
||||
(file.schemaVersion as number) < 0 ||
|
||||
typeof file.hostId !== 'string'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const schemaVersion = file.schemaVersion as number
|
||||
if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION &&
|
||||
(typeof file.records !== 'object' || file.records === null || Array.isArray(file.records))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION &&
|
||||
(typeof file.operations !== 'object' ||
|
||||
file.operations === null ||
|
||||
Array.isArray(file.operations) ||
|
||||
!Array.isArray(file.retiredClaimKeys) ||
|
||||
typeof file.unusableRecords !== 'object' ||
|
||||
file.unusableRecords === null ||
|
||||
Array.isArray(file.unusableRecords))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const state = emptyState(hostId)
|
||||
state.schemaVersion = schemaVersion
|
||||
state.hostId = file.hostId
|
||||
if (typeof file.records === 'object' && file.records !== null) {
|
||||
for (const [sessionId, value] of Object.entries(file.records)) {
|
||||
const decoded = isPersistedAgentSessionRecord(value)
|
||||
? decodePersistedAgentSessionRecord(value)
|
||||
: null
|
||||
const record = decoded?.record ?? null
|
||||
if (record?.sessionId === sessionId) {
|
||||
state.records.set(sessionId, record)
|
||||
} else {
|
||||
const valueSchemaVersion =
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as { schemaVersion?: unknown }).schemaVersion
|
||||
const reason = record
|
||||
? 'record_key_session_id_mismatch'
|
||||
: valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION
|
||||
? 'current_shape_invalid'
|
||||
: 'unsupported_schema'
|
||||
state.unreadableRecords.set(sessionId, { reason, raw: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) {
|
||||
for (const [sessionId, value] of Object.entries(file.unusableRecords)) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
const unusable = value as { reason?: unknown; raw?: unknown }
|
||||
if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) {
|
||||
if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw })
|
||||
}
|
||||
}
|
||||
if (typeof file.operations === 'object' && file.operations !== null) {
|
||||
for (const [key, value] of Object.entries(file.operations)) {
|
||||
if (!isAgentSessionOperationRow(value)) {
|
||||
if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) {
|
||||
if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
state.operations.set(key, value)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(file.retiredClaimKeys)) {
|
||||
for (const entry of file.retiredClaimKeys) {
|
||||
const key = entry as Partial<RetiredAgentSessionClaimKey>
|
||||
if (
|
||||
typeof key?.keyId !== 'string' ||
|
||||
key.keyId.length === 0 ||
|
||||
key.keyId.length > 512 ||
|
||||
!Number.isSafeInteger(key.retiredAt) ||
|
||||
(key.retiredAt as number) < 0
|
||||
) {
|
||||
if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
continue
|
||||
}
|
||||
state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number })
|
||||
}
|
||||
}
|
||||
const sessionTabs = parseAgentSessionTabTable(
|
||||
file,
|
||||
state.records,
|
||||
schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION
|
||||
)
|
||||
if (!sessionTabs.valid) {
|
||||
return null
|
||||
}
|
||||
state.sessionTabs = sessionTabs.table
|
||||
return { state }
|
||||
}
|
||||
|
||||
/** A record the primary retained as unreadable may still have a valid copy in the previous
|
||||
* committed state. Adopting it keeps the session reachable — the lease is re-adjudicated
|
||||
* like any other — while the unreadable bytes stay quarantined verbatim. */
|
||||
async function salvageUnreadableRecordsFromBackup(
|
||||
state: AgentSessionStoreState,
|
||||
backupFilePath: string,
|
||||
hostId: string
|
||||
): Promise<void> {
|
||||
const missing = [...state.unreadableRecords.keys()].filter(
|
||||
(sessionId) => !state.records.has(sessionId)
|
||||
)
|
||||
if (missing.length === 0) {
|
||||
return
|
||||
}
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(backupFilePath, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const backup = parseState(raw, hostId)
|
||||
if (!backup) {
|
||||
return
|
||||
}
|
||||
for (const sessionId of missing) {
|
||||
const record = backup.state.records.get(sessionId)
|
||||
if (record) {
|
||||
state.records.set(sessionId, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgentSessionStore(
|
||||
filePath: string,
|
||||
hostId: string
|
||||
): Promise<LoadedAgentSessionStore> {
|
||||
let unusableStoreFound = false
|
||||
for (const [candidate, recoveredFromBackup] of [
|
||||
[filePath, false],
|
||||
[backupPath(filePath), true]
|
||||
] as const) {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(candidate, 'utf-8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Only a missing or unparseable primary means "fall back". A transient read failure
|
||||
// (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not
|
||||
// replaced by a stale backup, and a backup that could not be read is not unusable.
|
||||
throw new Error('agent_session_store_corrupt', { cause: error })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const parsed = parseState(raw, hostId)
|
||||
if (!parsed) {
|
||||
unusableStoreFound = true
|
||||
continue
|
||||
}
|
||||
if (!recoveredFromBackup) {
|
||||
await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId)
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
storeFound: true,
|
||||
readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
recoveredFromBackup
|
||||
}
|
||||
}
|
||||
if (unusableStoreFound) {
|
||||
throw new Error('agent_session_store_corrupt')
|
||||
}
|
||||
return {
|
||||
state: emptyState(hostId),
|
||||
storeFound: false,
|
||||
readOnly: false,
|
||||
recoveredFromBackup: false
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | n
|
||||
async function openRecordStore(
|
||||
location: AgentSessionRecordStoreLocation
|
||||
): Promise<OpenedAgentSessionRecordStore> {
|
||||
const journalDatabase = await openStructuredAgentSessionJournalDatabase(location)
|
||||
const journalDatabase = openStructuredAgentSessionJournalDatabase(location)
|
||||
try {
|
||||
return {
|
||||
journalDatabase,
|
||||
|
||||
@@ -19,17 +19,12 @@ import {
|
||||
} from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import {
|
||||
AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
type RetiredAgentSessionClaimKey
|
||||
} from './agent-session-record-store-file'
|
||||
import type { RetiredAgentSessionClaimKey } from './agent-session-store-state'
|
||||
|
||||
const TEST_HOST_ID = 'local'
|
||||
|
||||
/** One committed state of the store, as tests seed it and read it back. */
|
||||
export type PersistedTestAgentSessionStore = {
|
||||
schemaVersion: number
|
||||
hostId: string
|
||||
/** Every record row as stored, including one this build cannot read. */
|
||||
records: Record<string, PersistedAgentSessionRecord>
|
||||
operations: Record<string, AgentSessionOperationRow>
|
||||
@@ -118,20 +113,23 @@ export function storedTestAgentSessionRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */
|
||||
/** Leaves `records`, and any tab index, behind as an earlier run of the app would have, before
|
||||
* anything opens it. */
|
||||
export async function seedTestAgentSessionRecordStore(
|
||||
stateDirectory: string,
|
||||
seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] }
|
||||
seed: {
|
||||
records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[]
|
||||
sessionTabs?: { tabId: string; sessionId: string }[]
|
||||
}
|
||||
): Promise<void> {
|
||||
writePersisted(databaseFor(stateDirectory), {
|
||||
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
hostId: TEST_HOST_ID,
|
||||
records: Object.fromEntries(
|
||||
seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)])
|
||||
),
|
||||
operations: {},
|
||||
retiredClaimKeys: [],
|
||||
unusableRecords: {}
|
||||
unusableRecords: {},
|
||||
...(seed.sessionTabs ? { sessionTabs: seed.sessionTabs } : {})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +152,6 @@ export async function readPersistedTestAgentSessionStore(
|
||||
): Promise<PersistedTestAgentSessionStore> {
|
||||
const db = databaseFor(stateDirectory)
|
||||
const persisted: PersistedTestAgentSessionStore = {
|
||||
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
hostId: TEST_HOST_ID,
|
||||
records: {},
|
||||
operations: {},
|
||||
retiredClaimKeys: [],
|
||||
|
||||
@@ -65,7 +65,7 @@ import {
|
||||
type AgentSessionReserveRequest,
|
||||
type AgentSessionReserveResult
|
||||
} from './agent-session-reservation-admission'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import {
|
||||
agentSessionVisibleTabIndex,
|
||||
listVisibleAgentSessionIds,
|
||||
@@ -101,7 +101,7 @@ export class AgentSessionRecordStore {
|
||||
journalDatabase: JournalHostDatabase
|
||||
hostId: string
|
||||
}): AgentSessionRecordStore {
|
||||
const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId)
|
||||
const rows = loadAgentSessionStoreRows(args.journalDatabase.db)
|
||||
const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows)
|
||||
return new AgentSessionRecordStore(transactions, args.hostId)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
applyAgentSessionReservation,
|
||||
type AgentSessionReserveRequest
|
||||
} from './agent-session-reservation-admission'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import {
|
||||
claudeProviderHandle,
|
||||
codexProviderHandle
|
||||
@@ -67,8 +67,6 @@ function reserveRequest(
|
||||
|
||||
function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
hostId: 'local',
|
||||
records: new Map(records.map((record) => [record.sessionId, record])),
|
||||
operations: new Map(),
|
||||
retiredClaimKeys: [],
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
reserveAgentSessionOwner,
|
||||
type AgentSessionReservation
|
||||
} from './agent-session-lease-transitions'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { agentSessionRecordIdentityFields } from './agent-session-record-founding'
|
||||
|
||||
export type AgentSessionReserveRequest = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target'
|
||||
import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions'
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stor
|
||||
import type {
|
||||
AgentSessionStoreState,
|
||||
RetiredAgentSessionClaimKey
|
||||
} from './agent-session-record-store-file'
|
||||
} from './agent-session-store-state'
|
||||
import {
|
||||
isReadableAgentSessionStoreOperation,
|
||||
isReadableAgentSessionStoreRecord,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease'
|
||||
import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id'
|
||||
import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file'
|
||||
import type { RetiredAgentSessionClaimKey } from './agent-session-store-state'
|
||||
import type { PersistedAgentSessionTab } from './agent-session-tab-table'
|
||||
|
||||
/** Valid stored identity, independent of provider availability. */
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// The agent-session store's in-memory state: loaded from the chat journal database and written
|
||||
// back to it row by row (agent-session-record-rows.ts).
|
||||
|
||||
import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionTabTable } from './agent-session-tab-table'
|
||||
|
||||
export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number }
|
||||
|
||||
export type AgentSessionStoreState = {
|
||||
records: Map<string, AgentSessionRecord>
|
||||
operations: Map<string, AgentSessionOperationRow>
|
||||
retiredClaimKeys: RetiredAgentSessionClaimKey[]
|
||||
/** Rows this build cannot validate, kept with a durable refusal reason. */
|
||||
unreadableRecords: Map<string, { reason: string; raw: unknown }>
|
||||
/** Chat tab id → the conversation it shows; null until this store first records a tab. */
|
||||
sessionTabs: AgentSessionTabTable | null
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j
|
||||
import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer'
|
||||
import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure'
|
||||
import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import { writeAgentSessionStoreRows } from './agent-session-record-rows'
|
||||
import {
|
||||
agentSessionStoreDraftRowWrites,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
|
||||
import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
import type { AgentSessionStoreState } from './agent-session-record-store-file'
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
|
||||
/**
|
||||
* Which conversation each structured chat tab shows, keyed by the host tab id.
|
||||
@@ -119,19 +117,11 @@ export function listVisibleAgentSessionIds(state: AgentSessionStoreState): strin
|
||||
return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId))
|
||||
}
|
||||
|
||||
/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */
|
||||
export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): {
|
||||
present: boolean
|
||||
sessionIds: string[]
|
||||
} {
|
||||
return {
|
||||
present: state.sessionTabs !== null,
|
||||
sessionIds: state.sessionTabs
|
||||
? listVisibleAgentSessionIds(state)
|
||||
: (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) =>
|
||||
state.records.has(sessionId)
|
||||
)
|
||||
}
|
||||
return { present: state.sessionTabs !== null, sessionIds: listVisibleAgentSessionIds(state) }
|
||||
}
|
||||
|
||||
export function setAgentSessionTabVisibility(
|
||||
@@ -157,126 +147,9 @@ export function showAgentSessionTabs(
|
||||
): void {
|
||||
for (const sessionId of sessionIds) {
|
||||
if (state.records.has(sessionId)) {
|
||||
setAgentSessionTabVisibility(
|
||||
state,
|
||||
sessionId,
|
||||
true,
|
||||
state.unrecordedSessionTabs?.tabIdFor(sessionId)
|
||||
)
|
||||
setAgentSessionTabVisibility(state, sessionId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PersistedAgentSessionTab = { tabId: string; sessionId: string }
|
||||
|
||||
/**
|
||||
* Reads the records file's table, or seeds it from what older builds wrote: the visible session list
|
||||
* and, for a chat created by a build that recorded one, the tab id on its record. That record field
|
||||
* is read here and nowhere else, and only when the file carries no table.
|
||||
*/
|
||||
export function parseAgentSessionTabTable(
|
||||
file: { sessionTabs?: unknown; visibleSessionIds?: unknown },
|
||||
records: ReadonlyMap<string, AgentSessionRecord>,
|
||||
strict: boolean
|
||||
): { valid: boolean; table: AgentSessionTabTable | null } {
|
||||
if (file.sessionTabs !== undefined) {
|
||||
return parsePersistedTabs(file.sessionTabs, strict)
|
||||
}
|
||||
if (file.visibleSessionIds === undefined) {
|
||||
return { valid: true, table: null }
|
||||
}
|
||||
if (!Array.isArray(file.visibleSessionIds)) {
|
||||
return { valid: !strict, table: null }
|
||||
}
|
||||
return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id
|
||||
* through every clear, so the chat now showing the chain's latest conversation seeds under the
|
||||
* first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared
|
||||
* conversation reopened from history is the later tab, and takes a fresh id if its own is held.
|
||||
*/
|
||||
function seedFromVisibleSessions(
|
||||
visible: readonly unknown[],
|
||||
records: ReadonlyMap<string, AgentSessionRecord>
|
||||
): AgentSessionTabTable {
|
||||
const sessionIds = [...new Set(visible.filter(isAgentSessionId))]
|
||||
const clearedFrom = new Map<string, string>()
|
||||
for (const record of records.values()) {
|
||||
const command = record.conversationCommand
|
||||
if (
|
||||
command?.command === 'clear' &&
|
||||
command.phase === 'committed' &&
|
||||
command.replacementSessionId &&
|
||||
!clearedFrom.has(command.replacementSessionId)
|
||||
) {
|
||||
clearedFrom.set(command.replacementSessionId, record.sessionId)
|
||||
}
|
||||
}
|
||||
const clearedTo = new Set(clearedFrom.values())
|
||||
const chainRoot = (sessionId: string): string => {
|
||||
const seen = new Set([sessionId])
|
||||
let current = sessionId
|
||||
let prior = clearedFrom.get(current)
|
||||
while (prior !== undefined && !seen.has(prior)) {
|
||||
seen.add(prior)
|
||||
current = prior
|
||||
prior = clearedFrom.get(current)
|
||||
}
|
||||
return current
|
||||
}
|
||||
const recordedOrDerived = (sessionId: string): string[] => {
|
||||
const record = records.get(sessionId)
|
||||
const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined
|
||||
return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId)
|
||||
}
|
||||
const tabIds = new Map<string, string>()
|
||||
const taken = new Set<string>()
|
||||
const held = (tabId: string): boolean => taken.has(tabId)
|
||||
const assign = (sessionId: string, candidates: readonly string[]): void => {
|
||||
const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held)
|
||||
taken.add(tabId)
|
||||
tabIds.set(sessionId, tabId)
|
||||
}
|
||||
const holdsChainTab = (sessionId: string): boolean =>
|
||||
!clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId
|
||||
for (const sessionId of sessionIds.filter(holdsChainTab)) {
|
||||
assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)])
|
||||
}
|
||||
for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) {
|
||||
assign(sessionId, recordedOrDerived(sessionId))
|
||||
}
|
||||
// In the visible list's order, which is the order older builds restored tabs in.
|
||||
return new AgentSessionTabTable(
|
||||
sessionIds.flatMap((sessionId) => {
|
||||
const tabId = tabIds.get(sessionId)
|
||||
return tabId === undefined ? [] : [[tabId, sessionId] as const]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function parsePersistedTabs(
|
||||
raw: unknown,
|
||||
strict: boolean
|
||||
): { valid: boolean; table: AgentSessionTabTable | null } {
|
||||
if (!Array.isArray(raw)) {
|
||||
return { valid: !strict, table: null }
|
||||
}
|
||||
const table = new AgentSessionTabTable()
|
||||
for (const entry of raw) {
|
||||
const tabId: unknown = entry?.tabId
|
||||
const sessionId: unknown = entry?.sessionId
|
||||
const wellFormed =
|
||||
isAgentSessionSurfaceTabId(tabId) &&
|
||||
isAgentSessionId(sessionId) &&
|
||||
table.sessionIdFor(tabId) === undefined &&
|
||||
table.tabIdFor(sessionId) === undefined
|
||||
if (wellFormed) {
|
||||
table.show(sessionId, tabId)
|
||||
} else if (strict) {
|
||||
return { valid: false, table: null }
|
||||
}
|
||||
}
|
||||
return { valid: true, table }
|
||||
}
|
||||
|
||||
@@ -61,11 +61,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
const profileIds = collectSavedStructuredAgentSessionIds(
|
||||
this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null
|
||||
)
|
||||
// Unrecorded, the profile's chats join the tabs chats opened while the import was owed left.
|
||||
// First: after a /clear the profile's chat would take their tab id, so seeds hit tabIdTaken.
|
||||
const targets = persistedVisibleIndex.present
|
||||
? persistedVisibleIndex.sessionIds
|
||||
: [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])]
|
||||
const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds
|
||||
await host?.restoreReadableSessions(targets)
|
||||
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
|
||||
@@ -95,12 +91,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false })
|
||||
}
|
||||
const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable
|
||||
// No host, or one still owed the records file's chats, means no one can say which chats exist;
|
||||
// with none on disk, empty is the answer.
|
||||
const importOwed =
|
||||
typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed()
|
||||
// No host means no one can say which chats exist; with none on disk, empty is the answer.
|
||||
this.structuredAgentSessionInventoryUnverifiable =
|
||||
(!host || importOwed) && this.hasPersistedStructuredAgentSessionStore()
|
||||
!host && this.hasPersistedStructuredAgentSessionStore()
|
||||
// This restore published quietly; subscribers still hold the frames that said "cannot tell".
|
||||
if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) {
|
||||
this.notifyMobileSessionTabSnapshots()
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('agentSession.reveal', () => {
|
||||
})
|
||||
|
||||
it('publishes the tab even when the journal could not be read', async () => {
|
||||
// A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth
|
||||
// An unreadable chat restores to nothing, but attach still recovers it, so the tab is worth
|
||||
// publishing and the pane's hold finishes the job. Refusing here would strand it forever.
|
||||
hostCalls.revealSession.mockResolvedValueOnce({
|
||||
sessionId: SESSION,
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// Opening the chat journal database for the host install. The open that migrates it to version 4
|
||||
// first reads the records file it replaces, so the copy runs in the migration's transaction.
|
||||
// Opening the chat journal database for the host install.
|
||||
|
||||
import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure'
|
||||
import {
|
||||
legacyAgentSessionRecordImport,
|
||||
readLegacyAgentSessionRecords,
|
||||
type LegacyAgentSessionRecordImportReport
|
||||
} from './agent-session-legacy-record-import'
|
||||
import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal'
|
||||
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
|
||||
@@ -31,19 +25,12 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown
|
||||
|
||||
/** The journal database. A refusal is recorded for the gate and thrown to the caller; the next
|
||||
* install tries again. */
|
||||
export async function openStructuredAgentSessionJournalDatabase(args: {
|
||||
export function openStructuredAgentSessionJournalDatabase(args: {
|
||||
stateDirectory: string
|
||||
hostId: string
|
||||
logger: StructuredAgentSessionLogger
|
||||
}): Promise<JournalHostDatabase> {
|
||||
}): JournalHostDatabase {
|
||||
try {
|
||||
const opened = await JournalHostDatabase.open(args.stateDirectory, async () =>
|
||||
legacyAgentSessionRecordImport(
|
||||
await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId),
|
||||
args.hostId,
|
||||
(report) => reportLegacyRecordImport(args.logger, report)
|
||||
)
|
||||
)
|
||||
const opened = JournalHostDatabase.open(args.stateDirectory)
|
||||
recordStructuredAgentSessionHostInstallRefusal(null)
|
||||
lastLoggedOpenFailure = null
|
||||
return opened
|
||||
@@ -55,15 +42,3 @@ export async function openStructuredAgentSessionJournalDatabase(args: {
|
||||
throw refusal
|
||||
}
|
||||
}
|
||||
|
||||
function reportLegacyRecordImport(
|
||||
logger: StructuredAgentSessionLogger,
|
||||
report: LegacyAgentSessionRecordImportReport
|
||||
): void {
|
||||
const { kind, ...fields } = report
|
||||
logger.warn('importing the chat records file did not complete', {
|
||||
scope: 'legacy-record-import',
|
||||
outcome: kind,
|
||||
...fields
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// The records file's one-time copy never keeps the host from installing. A file no read will make
|
||||
// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later
|
||||
// launch makes it, the chat list says it cannot tell rather than "none".
|
||||
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database'
|
||||
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { legacyAgentSessionStorePath } from './agent-session-record-store-file'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import {
|
||||
ensureStructuredAgentSessionHost,
|
||||
stopStructuredAgentSessionRuntime
|
||||
} from './structured-agent-session-runtime'
|
||||
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const IMPORTED = 'session-alpha-1'
|
||||
const CREATED_WHILE_OWED = 'session-new-0001'
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopStructuredAgentSessionRuntime().catch(() => undefined)
|
||||
vi.restoreAllMocks()
|
||||
await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeLegacy(contents: string): Promise<void> {
|
||||
await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true })
|
||||
await writeFile(legacyAgentSessionStorePath(root), contents)
|
||||
}
|
||||
|
||||
const legacyFile = (): string =>
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
hostId: 'local',
|
||||
records: { [IMPORTED]: storedTestAgentSessionRecord(agentSessionRecordFixture()) },
|
||||
operations: {},
|
||||
retiredClaimKeys: [],
|
||||
unusableRecords: {}
|
||||
})
|
||||
|
||||
function databaseVersion(): number {
|
||||
const db = new Database(journalDatabasePath(root))
|
||||
try {
|
||||
return journalPragmaNumber(db, 'user_version')
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */
|
||||
function startupRuntime(log: ReturnType<typeof recordingStructuredAgentSessionLogger>) {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon.
|
||||
const internal = runtime as unknown as {
|
||||
hasPersistedStructuredAgentSessionStore(): boolean
|
||||
ensureStructuredAgentSessionHost(): Promise<StructuredAgentSessionHost>
|
||||
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
|
||||
structuredAgentSessionInventoryUnverifiable: boolean
|
||||
}
|
||||
internal.hasPersistedStructuredAgentSessionStore = () => true
|
||||
internal.ensureStructuredAgentSessionHost = () =>
|
||||
ensureStructuredAgentSessionHost({
|
||||
logger: log.logger,
|
||||
stateDirectory: root,
|
||||
hostId: 'local',
|
||||
claimKeyId: 'key-1',
|
||||
resolveWorkspacePath: async () => root,
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true })
|
||||
})
|
||||
internal.refreshMobileSessionPtyRecords = async () => new Set<string>()
|
||||
return {
|
||||
runtime,
|
||||
host: () => internal.ensureStructuredAgentSessionHost(),
|
||||
unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable
|
||||
}
|
||||
}
|
||||
|
||||
/** Each import report the install logged, its kind as the entry's outcome. */
|
||||
function importReports(log: ReturnType<typeof recordingStructuredAgentSessionLogger>): unknown[] {
|
||||
return log.entries
|
||||
.filter((entry) => entry.fields.scope === 'legacy-record-import')
|
||||
.map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest }))
|
||||
}
|
||||
|
||||
// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'installs while the file cannot be read, says it cannot tell, and copies it once it can',
|
||||
async () => {
|
||||
await writeLegacy(legacyFile())
|
||||
await chmod(legacyAgentSessionStorePath(root), 0o000)
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const first = startupRuntime(log)
|
||||
|
||||
await first.runtime.restoreStructuredAgentSessionTabs()
|
||||
const host = await first.host()
|
||||
|
||||
expect(importReports(log)).toEqual([
|
||||
{
|
||||
kind: 'unavailable',
|
||||
error: expect.objectContaining({ message: 'agent_session_store_corrupt' })
|
||||
}
|
||||
])
|
||||
expect(host.legacyRecordImportOwed()).toBe(true)
|
||||
expect(first.unverifiable()).toBe(true)
|
||||
expect(databaseVersion()).toBe(3)
|
||||
// A chat started meanwhile lives in the database and keeps its row through the copy.
|
||||
await host.deps.store.reserveOwner({
|
||||
sessionId: CREATED_WHILE_OWED,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
provider: 'claude',
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' },
|
||||
expectedFence: null,
|
||||
spawnToken: 'spawn-new',
|
||||
claimKeyId: 'key-1',
|
||||
handoffOperationId: null,
|
||||
probe: { outcome: 'reservation-unused' },
|
||||
operation: {
|
||||
callerKey: 'client-1',
|
||||
operationId: `${NOW}-${'0'.repeat(31)}1`,
|
||||
fingerprint: 'fp-new'
|
||||
},
|
||||
now: NOW
|
||||
})
|
||||
await stopStructuredAgentSessionRuntime()
|
||||
|
||||
await chmod(legacyAgentSessionStorePath(root), 0o600)
|
||||
const second = startupRuntime(recordingStructuredAgentSessionLogger())
|
||||
await second.runtime.restoreStructuredAgentSessionTabs()
|
||||
const relaunched = await second.host()
|
||||
|
||||
expect(relaunched.legacyRecordImportOwed()).toBe(false)
|
||||
expect(second.unverifiable()).toBe(false)
|
||||
expect(databaseVersion()).toBe(4)
|
||||
expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull()
|
||||
expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => {
|
||||
await writeLegacy('{ truncated')
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const { runtime, host } = startupRuntime(log)
|
||||
|
||||
await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined()
|
||||
|
||||
expect(importReports(log)).toEqual([
|
||||
{ kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) }
|
||||
])
|
||||
expect((await host()).legacyRecordImportOwed()).toBe(false)
|
||||
expect(databaseVersion()).toBe(4)
|
||||
await stopStructuredAgentSessionRuntime()
|
||||
expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated')
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema'
|
||||
import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths'
|
||||
import {
|
||||
openTestAgentSessionRecordStore,
|
||||
readPersistedTestAgentSessionStore,
|
||||
@@ -86,9 +85,6 @@ describe('structured session rollback compatibility', () => {
|
||||
runtimeKind: 'native'
|
||||
})
|
||||
await seedTestAgentSessionRecordStore(root, { records: [record] })
|
||||
const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION })
|
||||
await mkdir(journalDir, { recursive: true })
|
||||
await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n')
|
||||
|
||||
const target = await openTestAgentSessionRecordStore(root)
|
||||
expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] })
|
||||
@@ -120,9 +116,6 @@ describe('structured session rollback compatibility', () => {
|
||||
const reloaded = await openTestAgentSessionRecordStore(root)
|
||||
expect(reloaded.listVisibleSessionIds()).toEqual([SESSION])
|
||||
expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1)
|
||||
await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe(
|
||||
'durable-journal-fixture\n'
|
||||
)
|
||||
expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([
|
||||
{ tabId: expect.any(String), sessionId: SESSION }
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentSessionClaimStatus,
|
||||
@@ -10,15 +10,10 @@ import type {
|
||||
} from '../../shared/agent-session-record'
|
||||
import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table'
|
||||
import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store'
|
||||
import {
|
||||
NO_LEGACY_JOURNAL_RECORDS,
|
||||
type JournalLegacyRecordImport
|
||||
} from '../native-chat/agent-session-journal/journal-database'
|
||||
import {
|
||||
JournalHostDatabase,
|
||||
journalDatabasePath
|
||||
} from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import { legacyAgentSessionStorePath } from './agent-session-record-store-file'
|
||||
import {
|
||||
createStructuredAgentSessionOwnerProbe,
|
||||
createStructuredAgentSessionOwnerProbes
|
||||
@@ -72,17 +67,9 @@ describe('structured agent-session store presence', () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function openProfileDatabase(
|
||||
legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS
|
||||
): Promise<JournalHostDatabase> {
|
||||
async function openProfileDatabase(): Promise<JournalHostDatabase> {
|
||||
profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-'))
|
||||
return JournalHostDatabase.openWith(profile, legacyRecords)
|
||||
}
|
||||
|
||||
async function writeRecordsFile(): Promise<void> {
|
||||
const filePath = legacyAgentSessionStorePath(profile)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, '{}')
|
||||
return JournalHostDatabase.open(profile)
|
||||
}
|
||||
|
||||
// Every host install creates the database, chats or not; startup restore must not wait on one.
|
||||
@@ -102,14 +89,6 @@ describe('structured agent-session store presence', () => {
|
||||
expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the records file answer while the database still owes its copy', async () => {
|
||||
;(await openProfileDatabase({ owed: true })).close()
|
||||
expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false)
|
||||
|
||||
await writeRecordsFile()
|
||||
expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a database it cannot read present', async () => {
|
||||
profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-'))
|
||||
await writeFile(journalDatabasePath(profile), 'not a database')
|
||||
@@ -117,26 +96,11 @@ describe('structured agent-session store presence', () => {
|
||||
expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true)
|
||||
})
|
||||
|
||||
// A profile from before the records moved into the database still holds a chat to import.
|
||||
it('checks the records file and its backup when the database is absent', () => {
|
||||
const fileExists = vi.fn((path: string) => path.endsWith('.bak'))
|
||||
|
||||
expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true)
|
||||
expect(fileExists).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
join('/profile', 'agent-sessions', 'agent-sessions.json')
|
||||
)
|
||||
expect(fileExists).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
join('/profile', 'agent-sessions', 'agent-sessions.json.bak')
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a fresh profile absent after three bounded presence checks', () => {
|
||||
it('reports a fresh profile absent after one presence check', () => {
|
||||
const fileExists = vi.fn(() => false)
|
||||
|
||||
expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false)
|
||||
expect(fileExists).toHaveBeenCalledTimes(3)
|
||||
expect(fileExists).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
releaseAgentSessionRecordStore,
|
||||
type OpenedAgentSessionRecordStore
|
||||
} from './agent-session-record-store-slot'
|
||||
import { legacyAgentSessionStorePath } from './agent-session-record-store-file'
|
||||
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database'
|
||||
import {
|
||||
@@ -64,26 +63,21 @@ import {
|
||||
} from './structured-agent-model-catalog-wiring'
|
||||
import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support'
|
||||
|
||||
/** Whether this profile holds a structured chat: a record or tab in the journal database, or the
|
||||
* records file a profile from before it carries while the database still owes its copy. */
|
||||
/** Whether this profile holds a structured chat: a record or tab in the journal database. */
|
||||
export function hasPersistedStructuredAgentSessionStore(
|
||||
stateDirectory: string,
|
||||
fileExists: (path: string) => boolean = existsSync
|
||||
): boolean {
|
||||
const databasePath = journalDatabasePath(stateDirectory)
|
||||
if (fileExists(databasePath)) {
|
||||
try {
|
||||
const holds = journalDatabaseHoldsAgentSessions(databasePath)
|
||||
if (holds !== undefined) {
|
||||
return holds
|
||||
}
|
||||
} catch {
|
||||
// A database that cannot be read cannot say it is empty.
|
||||
return true
|
||||
}
|
||||
if (!fileExists(databasePath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return journalDatabaseHoldsAgentSessions(databasePath)
|
||||
} catch {
|
||||
// A database that cannot be read cannot say it is empty.
|
||||
return true
|
||||
}
|
||||
const filePath = legacyAgentSessionStorePath(stateDirectory)
|
||||
return fileExists(filePath) || fileExists(`${filePath}.bak`)
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionRuntimeDeps = {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs
|
||||
// a bounded number of failed writes, not one per chat.
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from '../../shared/agent-session-record.test-fixture'
|
||||
import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import {
|
||||
closeTestJournalHostDatabase,
|
||||
closeTestJournalHostDatabases,
|
||||
openTestJournalHostDatabase,
|
||||
SAVED_BY_NEWER_ORCA
|
||||
} from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
|
||||
@@ -24,24 +26,19 @@ import { attachParamsForRecord } from '../native-chat/agent-session-wire/structu
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import type * as AgentSessionRecordRows from './agent-session-record-rows'
|
||||
import {
|
||||
AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
legacyAgentSessionStorePath
|
||||
} from './agent-session-record-store-file'
|
||||
import {
|
||||
readPersistedTestAgentSessionStore,
|
||||
seedTestAgentSessionStoreFromNewerBuild,
|
||||
storedTestAgentSessionRecord
|
||||
seedTestAgentSessionRecordStore,
|
||||
seedTestAgentSessionStoreFromNewerBuild
|
||||
} from './agent-session-record-store-test-harness'
|
||||
import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import {
|
||||
ensureStructuredAgentSessionHost,
|
||||
stopStructuredAgentSessionRuntime
|
||||
} from './structured-agent-session-runtime'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
|
||||
// `failing` fails every record write; `grants` lets that many more through, then fails.
|
||||
const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 }))
|
||||
@@ -65,7 +62,6 @@ const PROMPT = 'add a retry'
|
||||
const CHAT_A = 'chat-a-0001'
|
||||
const CHAT_B = 'chat-b-0002'
|
||||
const CLEARED = 'chat-s-0003'
|
||||
const OWED = 'chat-o-0004'
|
||||
|
||||
let root: string
|
||||
|
||||
@@ -119,34 +115,25 @@ function chatRecord(
|
||||
return { ...record, ...codex, ...clear }
|
||||
}
|
||||
|
||||
/** The records file a profile from before the chat database carries, which the install imports;
|
||||
* `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records
|
||||
* in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt,
|
||||
* accepted, so nothing is left to send. */
|
||||
/** The chat records the last run left; `visible` is their saved tab index, absent on a legacy
|
||||
* profile. `newer` leaves them in a database a newer Orca wrote. Then each chat's history as the
|
||||
* last run left it: one prompt, accepted, so nothing is left to send. */
|
||||
async function seedProfile(
|
||||
records: AgentSessionRecord[],
|
||||
options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {}
|
||||
) {
|
||||
await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true })
|
||||
await writeFile(
|
||||
legacyAgentSessionStorePath(root),
|
||||
JSON.stringify({
|
||||
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
|
||||
hostId: 'local',
|
||||
records: Object.fromEntries(
|
||||
records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)])
|
||||
),
|
||||
operations: {},
|
||||
retiredClaimKeys: [],
|
||||
unusableRecords: {},
|
||||
...(options.visible ? { visibleSessionIds: options.visible } : {})
|
||||
})
|
||||
)
|
||||
const database = await openStructuredAgentSessionJournalDatabase({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
stateDirectory: root,
|
||||
hostId: 'local'
|
||||
await seedTestAgentSessionRecordStore(root, {
|
||||
records,
|
||||
...(options.visible
|
||||
? {
|
||||
sessionTabs: options.visible.map((sessionId) => ({
|
||||
tabId: structuredAgentSessionTabId(sessionId),
|
||||
sessionId
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const database = openTestJournalHostDatabase(root)
|
||||
for (const record of options.history ?? records) {
|
||||
const fence = record.lease.runtimeFence
|
||||
const journal = await openAgentSessionJournal({
|
||||
@@ -171,33 +158,13 @@ async function seedProfile(
|
||||
})
|
||||
await journal.close()
|
||||
}
|
||||
database.close()
|
||||
closeTestJournalHostDatabase(root)
|
||||
if (options.newer) {
|
||||
await seedTestAgentSessionStoreFromNewerBuild(root)
|
||||
}
|
||||
return { path: journalDatabasePath(root) }
|
||||
}
|
||||
|
||||
/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */
|
||||
async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise<void> {
|
||||
// A directory where the file belongs: the read fails in a way that can clear.
|
||||
await mkdir(legacyAgentSessionStorePath(root), { recursive: true })
|
||||
const database = await openStructuredAgentSessionJournalDatabase({
|
||||
logger: createStructuredAgentSessionLogger(),
|
||||
stateDirectory: root,
|
||||
hostId: 'local'
|
||||
})
|
||||
database.db
|
||||
.prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)')
|
||||
.run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(record)))
|
||||
await AgentSessionRecordStore.open({
|
||||
journalDatabase: database,
|
||||
hostId: 'local'
|
||||
}).setSessionTabVisibility(record.sessionId, true, tabId)
|
||||
database.close()
|
||||
await rm(legacyAgentSessionStorePath(root), { recursive: true })
|
||||
}
|
||||
|
||||
function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) {
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const runtime = new OrcaRuntimeService()
|
||||
@@ -403,26 +370,6 @@ describe('restoring the chat tabs open at quit', () => {
|
||||
expect(writes.refused - prepared).toBe(2)
|
||||
})
|
||||
|
||||
// The profile never lists a Claude chat, so only the tab row that chat left brings it back.
|
||||
it("restores a chat opened while the copy was owed beside the profile's chats", async () => {
|
||||
const owed = chatRecord(OWED)
|
||||
await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed')
|
||||
const records = [chatRecord(CHAT_A, { codex: true })]
|
||||
await seedProfile(records, { history: [owed, ...records] })
|
||||
const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] })
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
|
||||
expect(published().map((tab) => tab.id)).toEqual([
|
||||
`agent-session:${OWED}`,
|
||||
`agent-session:${CHAT_A}`
|
||||
])
|
||||
expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([
|
||||
{ tabId: 'tab-opened-while-owed', sessionId: OWED },
|
||||
{ tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A }
|
||||
])
|
||||
})
|
||||
|
||||
it('still lists the chats when that write fails, and leaves the index absent', async () => {
|
||||
const records = legacyChats()
|
||||
await seedProfile(records)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// The only place a new fence number is chosen.
|
||||
//
|
||||
// Normally that is just "one past the current fence". For a record the one-time import took from
|
||||
// the records file's backup, or found beside a set-aside copy, it is not: the commit that never
|
||||
// landed may already have granted a fence the copy cannot show, and `isAgentSessionFenceCurrent`
|
||||
// compares with STRICT EQUALITY, so minting that exact number would hand a second writer a lease
|
||||
// the first one still believes it holds.
|
||||
// Normally that is just "one past the current fence". For a record an earlier build's one-time
|
||||
// import took from its records file's backup, or found beside a set-aside copy, it is not: the
|
||||
// commit that never landed may already have granted a fence the copy cannot show, and
|
||||
// `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact number would
|
||||
// hand a second writer a lease the first one still believes it holds.
|
||||
//
|
||||
// Recovery records the floor instead of rewriting the current fence, because `live` means a handle
|
||||
// proven at exactly the current fence — moving it would invalidate the very records recovery exists
|
||||
|
||||
@@ -119,10 +119,11 @@ export type AgentSessionLease = {
|
||||
/** True from load until the host adjudicates it; no writer is granted while set. */
|
||||
unreconciled: boolean
|
||||
/**
|
||||
* Lowest fence a future grant may use. Set only when the records file's copy came from its backup,
|
||||
* or sat beside a set-aside copy of the same chat: either may hide a fence already granted. The
|
||||
* CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number,
|
||||
* so rewriting it would invalidate the record it is trying to save.
|
||||
* Lowest fence a future grant may use. Set only by an earlier build's import of its records file,
|
||||
* when the copy came from its backup or sat beside a set-aside copy of the same chat: either may
|
||||
* hide a fence already granted. The CURRENT fence is deliberately left alone: `live` means a
|
||||
* handle proven at exactly that number, so rewriting it would invalidate the record it is trying
|
||||
* to save.
|
||||
*/
|
||||
minimumNextFence?: number
|
||||
/** Null on a released lease when nothing proved its owner gone. */
|
||||
|
||||
Reference in New Issue
Block a user