diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 8f947272dbc7..ff811983a253 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -26,12 +26,14 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** A row from a future schema was met: no writes, no deletion. */ + /** A row from a future schema, or of a kind this build does not know, was met: no writes, no + * deletion. */ readOnly: boolean /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ corrupt: boolean - /** Rows skipped because their body failed to parse (future-version rows are - * `readOnly`, never counted here). The store discloses these in the timeline. */ + /** Rows dropped because they failed to parse or name another sequence than their key (an + * unreadable row latches `readOnly`, never counted here). The store discloses these in the + * timeline. */ malformedRows: number /** Directory-internal: the first sequence of an unusable suffix. The store * deletes from here before it accepts a write; a probe leaves it alone. */ @@ -92,10 +94,11 @@ export function startJournalRowFold(input: JournalRowFoldInput): { const add = (entry: { seq: number; rowJson: string }): boolean => { const parsed = parseJournalRow(entry.rowJson) - if (!parsed.ok) { + // A body naming another sequence than its key is malformed there: writes number past the key. + if (!parsed.ok || parsed.row.seq !== entry.seq) { truncateFrom = entry.seq - latched = parsed.unreadable - malformedRows = parsed.unreadable ? 0 : 1 + latched = !parsed.ok && parsed.unreadable + malformedRows = latched ? 0 : 1 return false } const row = parsed.row diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index e65d5919b39b..9276b1a8c05f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -4,6 +4,14 @@ // in-place rewrite. A row whose version this build does not understand is // UNREADABLE, not skippable: the caller must degrade to read-only rather than // render a partial timeline or compact past a row it cannot interpret. +// +// A row whose KIND this build does not know is UNREADABLE the same way, and kept +// on disk, when it has the envelope every row keeps: a non-empty `epoch`, an +// integer `seq` >= 1, an integer `fence` and a numeric `ts`. So a new kind keeps +// that envelope; changing the envelope is a `v` bump. Builds from before this +// rule delete the journal from an unknown kind, so a new kind either ships its +// reader first and is written only once no supported build lacks that reader, +// or is written at a bumped `v`. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -79,10 +87,10 @@ export type JournalTombstoneRow = JournalRowBase & { queueResume?: true } -/** One Stop that took effect. Temporary carrier: a tombstone's extra key, because a released host - * deletes the journal from the first row kind it does not know (`journal-open.ts` then - * `journal-store-open.ts`) but ignores an unknown key; a row kind of its own once released hosts - * skip unknown kinds instead. */ +/** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, + * where a host from before the header's rule deletes the journal from a kind it does not know. A + * kind of its own ships its reader first and is written once no supported build lacks that + * reader, or is written at a bumped `v`: an older host must never fold past a person's Stop. */ export type JournalStopEvent = { /** Persisted: never rename an arm. Only `user-stop` pauses the queue. */ reason: StructuredAgentSessionStopCause @@ -180,25 +188,17 @@ export type JournalRowParse = | { ok: true; row: JournalRow } /** Malformed JSON or a shape this build rejects outright. */ | { ok: false; unreadable: false } - /** A future schema version. The host must not write or compact this journal. */ + /** A future schema version, or a kind this build does not know. The host must not write or + * compact this journal. */ | { ok: false; unreadable: true } -const ROW_KINDS = new Set([ - 'epoch', - 'item', - 'tombstone', - 'submission', - 'dispatch', - 'lifecycle-batch' -]) - export function serializeJournalRow(row: JournalRow): string { return JSON.stringify(row) } /** - * Parse one persisted line. Older versions are upcast; newer versions are - * reported as unreadable so the caller fails closed. + * Parse one persisted line. Older versions are upcast; newer versions and newer + * kinds are reported as unreadable so the caller fails closed. */ export function parseJournalRow(line: string): JournalRowParse { let parsed: unknown @@ -230,7 +230,13 @@ export function parseJournalRow(line: string): JournalRowParse { } } dropUnusableContextUsage(upcast) - return isJournalRow(upcast) ? { ok: true, row: upcast } : { ok: false, unreadable: false } + if (isJournalRow(upcast)) { + return { ok: true, row: upcast } + } + // A newer build's kind is placed by the envelope every row keeps; one without it is damage. + const { kind } = upcast + const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) + return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } } /** Linkage ids this build cannot trust, removed from a row it still keeps. @@ -317,60 +323,56 @@ function isPlainObject(value: unknown): value is Record { * schema — their nested shapes are dereferenced unguarded all the way to the * rendered surface, so a JSON-valid corruption must fail here, not there. */ function isJournalRow(record: Record): record is JournalRow { - if (typeof record.kind !== 'string' || !ROW_KINDS.has(record.kind)) { - return false - } - if ( - typeof record.epoch !== 'string' || - !record.epoch || - !Number.isInteger(record.seq) || - (record.seq as number) < 1 || - !Number.isInteger(record.fence) || - typeof record.ts !== 'number' - ) { - return false - } - if (record.kind === 'item') { - return ( - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body) - ) - } - if (record.kind === 'tombstone') { - return typeof record.itemId === 'string' && Number.isInteger(record.revision) - } - if (record.kind === 'submission') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body) - ) - } - if (record.kind === 'dispatch') { - return ( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.state === 'string' && - record.state.length > 0 && - (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string') - ) - } - if (record.kind === 'lifecycle-batch') { - return ( - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) - ) - } - return typeof record.reason === 'string' && isPlainObject(record.providerHandle) + const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined + return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) +} + +/** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails + * to compile, never reads as a newer build's kind. */ +const ROW_FIELD_CHECK_BY_KIND: Record< + JournalRow['kind'], + (record: Record) => boolean +> = { + epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + item: (record) => + typeof record.itemId === 'string' && + Number.isInteger(record.revision) && + isAdmissibleAgentJournalItemBody(record.body), + tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + submission: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) && + isAdmissibleAgentJournalMessageBody(record.body), + dispatch: (record) => + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string'), + 'lifecycle-batch': (record) => + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + Array.isArray(record.mutations) && + record.mutations.length > 0 && + record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && + record.mutations.every(isLifecycleMutation) +} +const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) + +/** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ +function hasJournalRowEnvelope(record: Record): boolean { + return ( + typeof record.epoch === 'string' && + record.epoch !== '' && + Number.isInteger(record.seq) && + Number(record.seq) >= 1 && + Number.isInteger(record.fence) && + typeof record.ts === 'number' + ) } function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts new file mode 100644 index 000000000000..794ca52ab7d5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -0,0 +1,276 @@ +// A newer build's row kind is never repaired away: like a newer row version, it latches this build +// read-only with every row kept, so the newer build still reads the whole chat after an upgrade. +// A row whose stored sequence and body disagree is damage at its stored sequence. + +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 { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { parseJournalRow, type JournalRow } from './journal-row-schema' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-kind', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-kind-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function itemIds(journal: { snapshot: () => { items: { itemId: string }[] } }): string[] { + return journal.snapshot().items.map((entry) => entry.itemId) +} + +/** What a newer build would write: a kind this build does not know, in the envelope every row keeps. */ +function newerRow(epoch: string, seq: number, extra: Record = {}) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: 5_000 + seq, + payload: { said: 'by a newer build', seq }, + ...extra + } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +/** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and + * reopens it as a restarted host would. */ +async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await journals.closeAll() + const { db } = openTestJournalHostDatabase(root) + edit((seq, json) => { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( + IDENTITY.sessionId, + seq + ) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) + }) + closeTestJournalHostDatabase(root) + return open() +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +function storedKinds(): [number, unknown][] { + return stored().map((row) => [row.seq, JSON.parse(row.rowJson).kind]) +} + +/** A journal of its anchor and one item, closed: the next row lands at sequence 3. */ +async function journalWithOneItem(): Promise { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'before' }, SCOPE) + return first.epoch +} + +/** One valid row of every kind this build writes; a kind added to the union without one here + * fails to compile. */ +function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { + const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } + const providerHandle = IDENTITY.providerHandle + return { + epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, + item: { + ...base, + seq: 2, + kind: 'item', + itemId: 'i', + revision: 1, + body: { kind: 'status', text: 'x' } + }, + tombstone: { ...base, seq: 3, kind: 'tombstone', itemId: 'i', revision: 2 }, + submission: { + ...base, + seq: 4, + kind: 'submission', + clientMessageId: 'c', + payloadFingerprint: 'f', + providerHandle, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }, + dispatch: { + ...base, + seq: 5, + kind: 'dispatch', + clientMessageId: 'c', + state: 'accepted', + providerItemId: null, + reason: null + }, + 'lifecycle-batch': { + ...base, + seq: 6, + kind: 'lifecycle-batch', + settlementId: 's', + mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] + } + } +} + +it("reads a row of every kind this build writes, never as a newer build's kind", () => { + for (const row of Object.values(rowOfEveryKind())) { + expect(parseJournalRow(JSON.stringify(row))).toEqual({ ok: true, row }) + } +}) + +describe('parsing a row of a kind this build does not know', () => { + it('reads one in the envelope every row keeps as unreadable', () => { + expect(parseJournalRow(JSON.stringify(newerRow('epoch-1', 7)))).toEqual({ + ok: false, + unreadable: true + }) + }) + + it.each([ + ['no sequence', { seq: undefined }], + ['a sequence of 0', { seq: 0 }], + ['a string sequence', { seq: '7' }], + ['a fractional fence', { fence: 1.5 }], + ['a string timestamp', { ts: '5007' }], + ['an empty epoch', { epoch: '' }], + ['an empty kind', { kind: '' }], + ['a kind that is not a string', { kind: 7 }] + ])('reads one with %s as malformed', (_name, broken) => { + const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a known kind that fails its own checks as malformed', () => { + const parsed = parseJournalRow( + JSON.stringify({ ...newerRow('epoch-1', 7), kind: 'item', itemId: 'x', revision: 1 }) + ) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a future schema version as unreadable before it looks at the kind', () => { + const parsed = parseJournalRow( + JSON.stringify(newerRow('epoch-1', 7, { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })) + ) + expect(parsed).toEqual({ ok: false, unreadable: true }) + }) +}) + +describe("a journal holding a newer build's row kind", () => { + it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => { + const first = await open() + for (const ordinal of [0, 1, 2, 3]) { + await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) + } + const epoch = first.epoch + await journals.closeAll() + const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' + const newer = JSON.stringify(newerRow(epoch, 3)) + + const journal = await restartAfter((put) => put(3, newer)) + const latched = stored() + expect(journal.isReadOnly).toBe(true) + expect(journal.repair).toEqual({ malformedRows: 0 }) + expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0']) + await expect( + journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + + const reopened = await restartAfter() + expect(reopened.isReadOnly).toBe(true) + expect(stored()).toEqual(latched) + expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) + + // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. + const upgraded = await restartAfter((put) => put(3, known)) + expect(upgraded.isReadOnly).toBe(false) + expect(itemIds(upgraded)).toHaveLength(4) + await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + expect(upgraded.cursor().sequence).toBe(6) + }) + + it('stays read-only with nothing deleted when the row names another sequence than its key', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + expect(journal.isReadOnly).toBe(true) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'future-mark'] + ]) + }) + + it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => { + const epoch = await journalWithOneItem() + const journal = await restartAfter((put) => + put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))) + ) + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 1 }) + // 3 is the disclosure the repair appends where the broken row was. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + }) +}) + +describe('a row whose body names another sequence than its stored key', () => { + it.each([ + ['an earlier', 2], + ['a later', 9] + ])( + 'is dropped at its key when it names %s one, and the next write succeeds', + async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone))) + expect(journal.repair).toEqual({ malformedRows: 1 }) + expect(journal.needsRebuild).toBe(true) + // The valid row at 2 stays; 3 is the disclosure the repair appends. + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'item'] + ]) + + await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE) + const reopened = await restartAfter() + expect(reopened.cursor().sequence).toBe(4) + expect(itemIds(reopened)).toHaveLength(3) + } + ) +}) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 548b263bbfec..e5f8125a5c0f 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -4,8 +4,9 @@ // so no class instances, Maps, or Dates. // // Rows are append-only. `schemaVersion` is upcast at read time and never -// rewritten in place, so a host that cannot read a row refuses to write the -// journal rather than skipping or compacting past it. +// rewritten in place, so a host that cannot read a row (a newer version, or a +// newer kind) refuses to write the journal rather than skipping or compacting +// past it. import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentSessionFailureRowWords } from './agent-session-failure-words' diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 20b64c7f6dbb..e585191c1a05 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -4,14 +4,18 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalItemIdentity, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' import Database from '../../../src/main/sqlite/sync-database' import { journalDatabasePath } from '../../../src/main/native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, createTrackedJournalOpener, - liveTestJournalRows + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -223,3 +227,64 @@ test("an older build opens this build's journal writable and appends to it; the rmSync(directory, { recursive: true, force: true }) } }, 120_000) + +// Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know +// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// its reader first and is written once no supported build lacks that reader, or is written at a +// bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the +// row too. +test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) + const journals = createTrackedJournalOpener() + const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) + try { + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem(item(0), { kind: 'status', text: 'before' }, scope) + const at = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch: journal.epoch, + seq: at, + fence: 1, + ts: 2_000, + payload: { said: 'by a newer build' } + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + at, + newer + ) + closeTestJournalHostDatabase(directory) + const rowsBefore = storedRows(directory) + + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.isReadOnly).toBe(true) + expect(reopened.repair).toEqual({ malformedRows: 0 }) + await expect( + reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + await journals.closeAll() + expect(storedRows(directory)).toEqual(rowsBefore) + expect(newerKinds()).toEqual([newer]) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport<() => OlderOpener>(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(newerKinds()).toEqual([]) + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000)