mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
# Conflicts: # mobile/src/session/use-mobile-native-chat-image-attachments.ts
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -22,6 +22,20 @@ function workingStatus(): AgentAwakeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentAwakeService status array ownership', () => {
|
||||
it('does not observe rows appended to the caller array after setStatuses', () => {
|
||||
const service = new AgentAwakeService()
|
||||
service.setMode('auto')
|
||||
const statuses: AgentAwakeStatus[] = [workingStatus()]
|
||||
|
||||
service.setStatuses(statuses)
|
||||
const before = service.getWorkingAgentCount()
|
||||
statuses.push(workingStatus(), workingStatus())
|
||||
|
||||
expect(service.getWorkingAgentCount()).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
function createBlocker() {
|
||||
const startedIds = new Set<number>()
|
||||
let nextId = 1
|
||||
|
||||
@@ -105,7 +105,8 @@ export class AgentAwakeService {
|
||||
}
|
||||
|
||||
setStatuses(statuses: AgentAwakeStatus[]): void {
|
||||
this.statuses = statuses.map((status) => ({ ...status }))
|
||||
// Copy the array, not every row: the hook server allocates each row fresh per event.
|
||||
this.statuses = [...statuses]
|
||||
this.refresh('status-change')
|
||||
}
|
||||
|
||||
@@ -171,7 +172,8 @@ export class AgentAwakeService {
|
||||
|
||||
private getEligibleRunningStatusCount(): number {
|
||||
const now = this.now()
|
||||
return this.statuses.filter((status) => this.isWakeEligible(status, now)).length
|
||||
// Counted in place: the filtered array was only ever measured, and this runs per hook event.
|
||||
return this.statuses.reduce((count, s) => count + (this.isWakeEligible(s, now) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
private isWakeEligible(status: AgentAwakeStatus, now: number): boolean {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeUsageRecord } from './transcript-record-parser'
|
||||
|
||||
function assistantLine(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
message: { usage: { input_tokens: 3, output_tokens: 5 } },
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('assistant-record prefilter', () => {
|
||||
it('still parses an ordinary assistant record', () => {
|
||||
expect(parseClaudeUsageRecord(assistantLine())?.inputTokens).toBe(3)
|
||||
})
|
||||
|
||||
it('rejects a user record that never mentions assistant', () => {
|
||||
const userLine = JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
message: { content: 'x'.repeat(200) }
|
||||
})
|
||||
|
||||
expect(parseClaudeUsageRecord(userLine)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a non-assistant record that happens to contain the word assistant', () => {
|
||||
const userLine = JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
message: { content: 'ask the assistant about this' }
|
||||
})
|
||||
|
||||
expect(parseClaudeUsageRecord(userLine)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -84,10 +84,30 @@ function dedupeClaudeUsageTurns(
|
||||
return deduped
|
||||
}
|
||||
|
||||
/**
|
||||
* Necessary condition for `JSON.parse(line).type === 'assistant'`, checked before the parse.
|
||||
*
|
||||
* Sound for any transcript written by a standard JSON serializer: `JSON.stringify` (which writes
|
||||
* these files) escapes only quotes, backslashes and control characters, never ASCII letters, so
|
||||
* the decoded value can only be `assistant` if the line spells it literally. The gate over-admits
|
||||
* freely — the `parsed.type` check below stays authoritative.
|
||||
*
|
||||
* A `\u`-escape fallback was measured and rejected: it costs a second full-line scan and made
|
||||
* transcripts whose tool results contain control characters 1.43x slower overall.
|
||||
*/
|
||||
function mayEncodeAssistantType(line: string): boolean {
|
||||
return line.includes('assistant')
|
||||
}
|
||||
|
||||
function parseClaudeUsageSourceRecord(
|
||||
line: string,
|
||||
fallbackSessionId: string | null = null
|
||||
): ClaudeUsageParsedSourceTurn | null {
|
||||
// Only assistant records carry usage, but transcripts interleave user/tool-result lines that
|
||||
// routinely embed whole files. Reject those before paying for a full parse.
|
||||
if (!mayEncodeAssistantType(line)) {
|
||||
return null
|
||||
}
|
||||
let parsed: ClaudeUsageSourceRecord
|
||||
try {
|
||||
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
findSelfInitiatedTreeKills,
|
||||
resetSelfInitiatedTreeKillLogForTest
|
||||
} from '../crash-reporting/self-initiated-tree-kill-log'
|
||||
import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown'
|
||||
|
||||
/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */
|
||||
const UNREACHABLE_PGID = 2_147_483_647
|
||||
|
||||
function child() {
|
||||
return {
|
||||
pid: 1234,
|
||||
@@ -10,6 +17,10 @@ function child() {
|
||||
}
|
||||
|
||||
describe('terminateCodexAppServerProcessTree', () => {
|
||||
beforeEach(() => {
|
||||
resetSelfInitiatedTreeKillLogForTest()
|
||||
})
|
||||
|
||||
it('waits for the Windows tree kill before releasing the wrapper', async () => {
|
||||
const target = child()
|
||||
const release = Promise.withResolvers<void>()
|
||||
@@ -120,6 +131,55 @@ describe('terminateCodexAppServerProcessTree', () => {
|
||||
expect(target.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was
|
||||
* ours. A group that had already exited was killed by nobody, so crediting it
|
||||
* puts a suspect in the five-second window that Orca never issued. Exercised
|
||||
* through the real `process.kill(-pgid)` because the swallow being tested
|
||||
* lives in the production default, not in an injectable seam.
|
||||
*/
|
||||
it('does not claim a snapshot group that was already gone', async () => {
|
||||
const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] }
|
||||
|
||||
await expect(
|
||||
terminateCodexAppServerProcessTree(target, undefined, {
|
||||
platform: 'darwin',
|
||||
captureDescendants: async () => ({
|
||||
rootPgid: UNREACHABLE_PGID,
|
||||
descendants: [],
|
||||
capturedAtMs: 1
|
||||
}),
|
||||
terminateDescendants: async () => true
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
|
||||
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([])
|
||||
})
|
||||
|
||||
it('claims a snapshot group the signal actually reached', async () => {
|
||||
const target = child()
|
||||
const signalProcessGroup = vi.fn()
|
||||
|
||||
await expect(
|
||||
terminateCodexAppServerProcessTree(target, undefined, {
|
||||
platform: 'darwin',
|
||||
captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }),
|
||||
terminateDescendants: async () => true,
|
||||
signalProcessGroup
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
|
||||
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
|
||||
expect.objectContaining({
|
||||
pid: 1234,
|
||||
site: 'codex-app-server-teardown',
|
||||
scope: 'posix-process-group'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
|
||||
const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true))
|
||||
const targets = killMocks.map((kill, index) => ({
|
||||
|
||||
@@ -128,19 +128,24 @@ async function terminatePosixTree(
|
||||
if (descendantsExited && snapshot.rootPgid === rootPid) {
|
||||
const signalGroup =
|
||||
deps.signalProcessGroup ??
|
||||
((pgid: number, signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(-pgid, signal)
|
||||
} catch {
|
||||
// Group already exited.
|
||||
}
|
||||
((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal))
|
||||
let groupSignalled = false
|
||||
try {
|
||||
signalGroup(snapshot.rootPgid, 'SIGKILL')
|
||||
groupSignalled = true
|
||||
} catch {
|
||||
// Already-gone is still the desired outcome, but nothing here killed it,
|
||||
// and a crumb for a kill we never landed is a false render-process-gone suspect.
|
||||
}
|
||||
if (groupSignalled) {
|
||||
// Outside the try, as in terminateDedicatedPosixGroup: that catch is the
|
||||
// already-gone contract, not a breadcrumb handler.
|
||||
recordSelfInitiatedTreeKill({
|
||||
pid: snapshot.rootPgid,
|
||||
site: 'codex-app-server-teardown',
|
||||
scope: 'posix-process-group'
|
||||
})
|
||||
signalGroup(snapshot.rootPgid, 'SIGKILL')
|
||||
recordSelfInitiatedTreeKill({
|
||||
pid: snapshot.rootPgid,
|
||||
site: 'codex-app-server-teardown',
|
||||
scope: 'posix-process-group'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!descendantsExited) {
|
||||
child.kill('SIGCONT')
|
||||
|
||||
@@ -20,10 +20,7 @@ export function codexJournalPromptIdPart(value: string): string {
|
||||
}
|
||||
const suffix = `#${digestPayload(value).slice(0, 32)}`
|
||||
const bounded = boundPayload(value, {
|
||||
inlineHeadBytes: CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES - suffix.length,
|
||||
maxSessionBytes: Number.MAX_SAFE_INTEGER,
|
||||
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
|
||||
appendWindowMs: Number.MAX_SAFE_INTEGER
|
||||
inlineHeadBytes: CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES - suffix.length
|
||||
})
|
||||
return `${bounded.head}${suffix}`
|
||||
}
|
||||
|
||||
@@ -17,14 +17,8 @@ export function codexStructuredItemKey(threadId: string, itemId: string): string
|
||||
return `${key.slice(0, 960)}:${(hash >>> 0).toString(16)}`
|
||||
}
|
||||
|
||||
export function pendingPatchBytes(pending: {
|
||||
body: unknown
|
||||
blobs: readonly { payload: string }[]
|
||||
}): number {
|
||||
return (
|
||||
Buffer.byteLength(JSON.stringify(pending.body), 'utf8') +
|
||||
pending.blobs.reduce((total, blob) => total + Buffer.byteLength(blob.payload, 'utf8'), 0)
|
||||
)
|
||||
export function pendingPatchBytes(pending: { body: unknown }): number {
|
||||
return Buffer.byteLength(JSON.stringify(pending.body), 'utf8')
|
||||
}
|
||||
|
||||
export function boundStreamItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
|
||||
@@ -24,7 +24,6 @@ export type CodexItemStreamState = {
|
||||
export type CodexPendingItemPatch = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: NonNullable<ReturnType<typeof codexJournalItem>['body']>
|
||||
blobs: ReturnType<typeof codexJournalItem>['blobs']
|
||||
}
|
||||
|
||||
export type CodexStructuredItemStreamAdmission =
|
||||
|
||||
@@ -103,8 +103,8 @@ export function createCodexStructuredItemStreams(
|
||||
}
|
||||
const options = { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}` }
|
||||
const admission = deps.sink.tryAppendItem
|
||||
? deps.sink.tryAppendItem(state.identity, translated.body, translated.blobs, options)
|
||||
: (deps.sink.appendItem(state.identity, translated.body, translated.blobs, options),
|
||||
? deps.sink.tryAppendItem(state.identity, translated.body, options)
|
||||
: (deps.sink.appendItem(state.identity, translated.body, options),
|
||||
{ accepted: true as const })
|
||||
if (!admission.accepted) {
|
||||
return false
|
||||
@@ -167,9 +167,8 @@ export function createCodexStructuredItemStreams(
|
||||
}
|
||||
for (const [key, pending] of pendingPatches) {
|
||||
const admission = deps.sink.tryAppendItem
|
||||
? deps.sink.tryAppendItem(pending.identity, pending.body, pending.blobs)
|
||||
: (deps.sink.appendItem(pending.identity, pending.body, pending.blobs),
|
||||
{ accepted: true as const })
|
||||
? deps.sink.tryAppendItem(pending.identity, pending.body)
|
||||
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
|
||||
if (!admission.accepted) {
|
||||
flushed = false
|
||||
continue
|
||||
@@ -193,9 +192,8 @@ export function createCodexStructuredItemStreams(
|
||||
return { accepted: true }
|
||||
}
|
||||
const admission = deps.sink.tryAppendItem
|
||||
? deps.sink.tryAppendItem(pending.identity, pending.body, pending.blobs)
|
||||
: (deps.sink.appendItem(pending.identity, pending.body, pending.blobs),
|
||||
{ accepted: true as const })
|
||||
? deps.sink.tryAppendItem(pending.identity, pending.body)
|
||||
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
@@ -237,8 +235,7 @@ export function createCodexStructuredItemStreams(
|
||||
if (translated.body) {
|
||||
const nextPending: CodexPendingItemPatch = {
|
||||
identity: state.identity,
|
||||
body: translated.body,
|
||||
blobs: translated.blobs
|
||||
body: translated.body
|
||||
}
|
||||
const previous = pendingPatches.get(key)
|
||||
const previousBytes = previous ? pendingPatchBytes(previous) : 0
|
||||
|
||||
@@ -220,12 +220,6 @@ describe('codex item bodies', () => {
|
||||
throw new Error('expected bounded command output')
|
||||
}
|
||||
expect(body.output.head.length).toBeLessThan(20_000)
|
||||
expect(translated.blobs).toEqual([
|
||||
{
|
||||
digest: body.output.digest,
|
||||
payload: output
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('continues to accept camel-case command completion output', () => {
|
||||
|
||||
@@ -172,7 +172,6 @@ function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed'
|
||||
|
||||
export type CodexJournalItem = {
|
||||
body: AgentJournalItemBody | null
|
||||
blobs: { digest: string; payload: string }[]
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
@@ -190,10 +189,6 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
|
||||
state: commandState(item),
|
||||
...(bounded === null ? {} : { output: bounded.bounded })
|
||||
},
|
||||
blobs:
|
||||
output !== null && bounded?.bounded.truncated
|
||||
? [{ digest: bounded.bounded.digest, payload: output }]
|
||||
: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
@@ -215,7 +210,6 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
|
||||
input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
state: commandState(item)
|
||||
},
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
@@ -227,7 +221,6 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
|
||||
path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`,
|
||||
patch: bounded
|
||||
},
|
||||
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: patch }] : [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
@@ -246,7 +239,6 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
blocks.length === 0
|
||||
? null
|
||||
: { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks },
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
@@ -266,14 +258,11 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
|
||||
text === null
|
||||
? null
|
||||
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text },
|
||||
blobs: [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item)
|
||||
return unhandled
|
||||
? { body: unhandled.body, blobs: unhandled.blobs, handled: false }
|
||||
: { body: null, blobs: [], handled: true }
|
||||
return unhandled ? { body: unhandled.body, handled: false } : { body: null, handled: true }
|
||||
}
|
||||
|
||||
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
|
||||
@@ -292,7 +281,7 @@ export function codexStreamingMessageBody(text: string): AgentJournalItemBody {
|
||||
/** Snapshot body for any item-level stream, keyed onto its parent item. */
|
||||
export function codexStreamingJournalItem(item: CodexThreadItem, text: string): CodexJournalItem {
|
||||
if (item.type === 'agentMessage') {
|
||||
return { body: codexStreamingMessageBody(text), blobs: [], handled: true }
|
||||
return { body: codexStreamingMessageBody(text), handled: true }
|
||||
}
|
||||
if (item.type === 'commandExecution') {
|
||||
return commandItem({ ...item, aggregatedOutput: text })
|
||||
@@ -304,10 +293,9 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string):
|
||||
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded
|
||||
return {
|
||||
body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded },
|
||||
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: text }] : [],
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
|
||||
return { body: { kind: 'status', text: bounded.text }, blobs: [], handled: true }
|
||||
return { body: { kind: 'status', text: bounded.text }, handled: true }
|
||||
}
|
||||
|
||||
@@ -91,13 +91,11 @@ export class CodexJournalGenericFrames {
|
||||
const admission = this.deps.sink.tryAppendItem
|
||||
? this.deps.sink.tryAppendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame:codex:${this.fallbackSequence}` },
|
||||
translated.body,
|
||||
translated.blobs
|
||||
translated.body
|
||||
)
|
||||
: (this.deps.sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame:codex:${this.fallbackSequence}` },
|
||||
translated.body,
|
||||
translated.blobs
|
||||
translated.body
|
||||
),
|
||||
CODEX_JOURNAL_ADMITTED)
|
||||
if (!admission.accepted) {
|
||||
@@ -136,13 +134,11 @@ export class CodexJournalGenericFrames {
|
||||
kind: 'status',
|
||||
text
|
||||
},
|
||||
[],
|
||||
{ coalescingKey: `provider-frame-suppressed:codex:${bucket}` }
|
||||
)
|
||||
: (this.deps.sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` },
|
||||
{ kind: 'status', text },
|
||||
[],
|
||||
{ coalescingKey: `provider-frame-suppressed:codex:${bucket}` }
|
||||
),
|
||||
CODEX_JOURNAL_ADMITTED)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-lifecycle-capacity'
|
||||
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement'
|
||||
import {
|
||||
codexItemIdentity,
|
||||
codexJournalItem,
|
||||
@@ -126,19 +126,13 @@ export class CodexJournalItems {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
if (method === 'item/completed') {
|
||||
const admission = appendCodexLifecycleItem(
|
||||
this.deps.sink,
|
||||
identity,
|
||||
translated.body,
|
||||
translated.blobs
|
||||
)
|
||||
const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body)
|
||||
return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission
|
||||
}
|
||||
const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {}
|
||||
const admission = this.deps.sink.tryAppendItem
|
||||
? this.deps.sink.tryAppendItem(identity, translated.body, translated.blobs, options)
|
||||
: (this.deps.sink.appendItem(identity, translated.body, translated.blobs),
|
||||
CODEX_JOURNAL_ADMITTED)
|
||||
? this.deps.sink.tryAppendItem(identity, translated.body, options)
|
||||
: (this.deps.sink.appendItem(identity, translated.body), CODEX_JOURNAL_ADMITTED)
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
|
||||
@@ -243,14 +243,12 @@ function appendLifecycleMutations(
|
||||
for (const mutation of chunk) {
|
||||
if (mutation.kind === 'item') {
|
||||
if (sink.tryAppendItem) {
|
||||
admission = sink.tryAppendItem(mutation.identity, mutation.body, [], {
|
||||
lifecycle: true
|
||||
})
|
||||
admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true })
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
} else {
|
||||
sink.appendItem(mutation.identity, mutation.body, [], { lifecycle: true })
|
||||
sink.appendItem(mutation.identity, mutation.body, { lifecycle: true })
|
||||
}
|
||||
} else {
|
||||
if (sink.tryAppendTombstone) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
StructuredAgentSessionEventSink,
|
||||
StructuredAgentSessionJournalBlob,
|
||||
StructuredAgentSessionSinkAdmission
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement'
|
||||
@@ -20,13 +19,12 @@ function criticalAdmission(
|
||||
export function appendCodexLifecycleItem(
|
||||
sink: StructuredAgentSessionEventSink,
|
||||
identity: AgentJournalItemIdentity,
|
||||
body: AgentJournalItemBody,
|
||||
blobs: readonly StructuredAgentSessionJournalBlob[] = []
|
||||
body: AgentJournalItemBody
|
||||
): CodexJournalTranslationAdmission {
|
||||
if (sink.tryAppendItem) {
|
||||
return criticalAdmission(sink.tryAppendItem(identity, body, blobs, { lifecycle: true }))
|
||||
return criticalAdmission(sink.tryAppendItem(identity, body, { lifecycle: true }))
|
||||
}
|
||||
sink.appendItem(identity, body, blobs, { lifecycle: true })
|
||||
sink.appendItem(identity, body, { lifecycle: true })
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,11 @@ describe('codex journal translation', () => {
|
||||
const { translator, tap } = translatorWith()
|
||||
let rejectTerminal = true
|
||||
const appendItem = tap.sink.appendItem
|
||||
tap.sink.tryAppendItem = (identity, body, blobs, options) => {
|
||||
tap.sink.tryAppendItem = (identity, body, options) => {
|
||||
if (rejectTerminal && body.kind === 'tool-call' && body.state === 'failed') {
|
||||
return { accepted: false as const, reason: 'backpressure' as const }
|
||||
}
|
||||
appendItem(identity, body, blobs, options)
|
||||
appendItem(identity, body, options)
|
||||
return { accepted: true as const }
|
||||
}
|
||||
for (let index = 0; index <= 256; index += 1) {
|
||||
|
||||
@@ -40,7 +40,6 @@ export function publishCodexTurnLifecycle(input: {
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: input.turnId, state: input.state }
|
||||
},
|
||||
[],
|
||||
{ lifecycle: true }
|
||||
)
|
||||
: (input.sink.appendItem(
|
||||
@@ -50,7 +49,6 @@ export function publishCodexTurnLifecycle(input: {
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: input.turnId, state: input.state }
|
||||
},
|
||||
[],
|
||||
{ lifecycle: true }
|
||||
),
|
||||
ADMITTED)
|
||||
|
||||
@@ -37,10 +37,7 @@ export class CodexTurnOrdinals {
|
||||
const suffix = `#${digestPayload(value).slice(0, 24)}`
|
||||
return `${
|
||||
boundPayload(encoded, {
|
||||
inlineHeadBytes: 256 - Buffer.byteLength(suffix, 'utf8'),
|
||||
maxSessionBytes: Number.MAX_SAFE_INTEGER,
|
||||
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
|
||||
appendWindowMs: Number.MAX_SAFE_INTEGER
|
||||
inlineHeadBytes: 256 - Buffer.byteLength(suffix, 'utf8')
|
||||
}).head
|
||||
}${suffix}`
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
|
||||
|
||||
// ─── System memory at gone time ─────────────────────────────────────
|
||||
// Why: the system outlives the crashed process, so this IS sampleable at
|
||||
// process-gone — it separates "renderer grew huge" from "machine out of
|
||||
// memory/commit", which the per-process buckets alone cannot.
|
||||
// Timing honesty: this reads AFTER the crashed process's memory returned to
|
||||
// the OS, so free/swapFree can look healthier than they were at kill time.
|
||||
// Platform honesty: swap* exist on Windows/Linux only. On Linux `free` is
|
||||
// /proc/meminfo MemFree and is NOT the pressure signal — it excludes page cache
|
||||
// and other reclaimable memory; `available` (MemAvailable, Linux-only) is. On
|
||||
// macOS `free` is near-meaningless (file cache and compression keep it low on
|
||||
// healthy machines); fileBacked/purgeable are the only reclaimability proxy this
|
||||
// API gives there, and none of these fields answers "was the machine under
|
||||
// pressure" on macOS — that needs a signal Electron does not expose.
|
||||
|
||||
type CrashReportDetails = Record<string, CrashReportDetailValue>
|
||||
|
||||
export function memoryKBFieldMB(value: unknown): number | undefined {
|
||||
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
|
||||
}
|
||||
|
||||
type SystemMemoryInfoLike = {
|
||||
total?: unknown
|
||||
free?: unknown
|
||||
available?: unknown
|
||||
swapTotal?: unknown
|
||||
swapFree?: unknown
|
||||
fileBacked?: unknown
|
||||
purgeable?: unknown
|
||||
}
|
||||
|
||||
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
|
||||
|
||||
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
|
||||
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
|
||||
.getSystemMemoryInfo
|
||||
if (typeof read !== 'function') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return read.call(process)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
|
||||
|
||||
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
|
||||
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
|
||||
}
|
||||
|
||||
export function getSystemMemoryAtGoneDetails(): CrashReportDetails {
|
||||
const info = systemMemoryInfoReader()
|
||||
if (!info) {
|
||||
return {}
|
||||
}
|
||||
const details: CrashReportDetails = {}
|
||||
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
|
||||
['total', 'systemMemoryTotalMB'],
|
||||
['free', 'systemMemoryFreeMB'],
|
||||
['available', 'systemMemoryAvailableMB'],
|
||||
['swapTotal', 'systemMemorySwapTotalMB'],
|
||||
['swapFree', 'systemMemorySwapFreeMB'],
|
||||
['fileBacked', 'systemMemoryFileBackedMB'],
|
||||
['purgeable', 'systemMemoryPurgeableMB']
|
||||
]
|
||||
for (const [field, key] of fields) {
|
||||
const mb = memoryKBFieldMB(info[field])
|
||||
if (mb !== undefined) {
|
||||
details[key] = mb
|
||||
}
|
||||
}
|
||||
return details
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getSystemMemoryDetails,
|
||||
setSystemMemoryInfoReaderForTest,
|
||||
withSwapVolumeFreeSpace
|
||||
} from './system-memory-details'
|
||||
import {
|
||||
readSwapVolumeFreeSpace,
|
||||
setSwapVolumeFreeSpaceReaderForTest,
|
||||
type SwapVolumeFreeSpace
|
||||
} from './swap-volume-free-space'
|
||||
import { samplePreGoneSystemMemory } from './pre-gone-host-memory'
|
||||
import {
|
||||
buildProcessGoneCrashDetails,
|
||||
resetPreGoneCrashSamplingForTest,
|
||||
samplePreGoneProcessMetrics,
|
||||
startPreGoneCrashSampling
|
||||
} from './process-gone-diagnostics'
|
||||
|
||||
type MetricFixture = {
|
||||
pid: number
|
||||
creationTime: number
|
||||
type: string
|
||||
memory: { workingSetSize: number; peakWorkingSetSize?: number; privateBytes?: number }
|
||||
}
|
||||
|
||||
const { appMetricsMock } = vi.hoisted(() => ({
|
||||
appMetricsMock: vi.fn<() => MetricFixture[]>(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getAppMetrics: appMetricsMock } }))
|
||||
|
||||
const BROWSER_AND_RENDERER: MetricFixture[] = [
|
||||
{ pid: 10, creationTime: 1, type: 'Browser', memory: { workingSetSize: 1024 * 250 } },
|
||||
{
|
||||
pid: 11,
|
||||
creationTime: 2,
|
||||
type: 'Tab',
|
||||
memory: { workingSetSize: 1024 * 400, peakWorkingSetSize: 1024 * 420, privateBytes: 1024 * 260 }
|
||||
}
|
||||
]
|
||||
|
||||
const BROWSER_ONLY: MetricFixture[] = [BROWSER_AND_RENDERER[0]]
|
||||
|
||||
const UNDER_COMMIT_PRESSURE = {
|
||||
total: 16_000 * 1024,
|
||||
free: 400 * 1024,
|
||||
swapTotal: 48_000 * 1024,
|
||||
swapFree: 200 * 1024
|
||||
}
|
||||
|
||||
const AFTER_THE_CORPSE_RELEASED = {
|
||||
total: 16_000 * 1024,
|
||||
free: 3_000 * 1024,
|
||||
swapTotal: 48_000 * 1024,
|
||||
swapFree: 2_900 * 1024
|
||||
}
|
||||
|
||||
// Commit limit ~= RAM: a disabled or fixed pagefile, which no amount of empty
|
||||
// disk can grow into. `swapTotal > total` is all this API can say about that.
|
||||
const FIXED_PAGEFILE_UNDER_PRESSURE = {
|
||||
total: 16_000 * 1024,
|
||||
free: 300 * 1024,
|
||||
swapTotal: 16_100 * 1024,
|
||||
swapFree: 180 * 1024
|
||||
}
|
||||
|
||||
const NO_PAGEFILE_UNDER_PRESSURE = {
|
||||
...FIXED_PAGEFILE_UNDER_PRESSURE,
|
||||
swapTotal: 15_900 * 1024
|
||||
}
|
||||
|
||||
const BEFORE_THE_STORM = {
|
||||
total: 16_000 * 1024,
|
||||
free: 9_000 * 1024,
|
||||
swapTotal: 48_000 * 1024,
|
||||
swapFree: 30_000 * 1024
|
||||
}
|
||||
|
||||
describe('pre-gone host memory', () => {
|
||||
beforeEach(() => {
|
||||
resetPreGoneCrashSamplingForTest()
|
||||
setSystemMemoryInfoReaderForTest(null)
|
||||
setSwapVolumeFreeSpaceReaderForTest(null)
|
||||
appMetricsMock.mockClear()
|
||||
appMetricsMock.mockReturnValue(BROWSER_AND_RENDERER)
|
||||
})
|
||||
|
||||
it('carries a pre-gone host reading, not only the post-mortem one', async () => {
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
|
||||
await samplePreGoneSystemMemory(Date.now() - 5_000)
|
||||
|
||||
// The renderer dies; its ~400 MB returns to the OS, so the gone-time read
|
||||
// now shows a much healthier machine than the one that refused the alloc.
|
||||
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
|
||||
appMetricsMock.mockReturnValue(BROWSER_ONLY)
|
||||
|
||||
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
|
||||
|
||||
expect(details.systemMemorySwapFreeMB).toBe(2_900)
|
||||
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(200)
|
||||
expect(details.systemMemoryPreGoneFreeMB).toBe(400)
|
||||
expect(details.systemMemoryPreGoneTotalMB).toBe(16_000)
|
||||
// Why: host memory keeps its own key family, so a `systemMemory` prefix scan sees both reads.
|
||||
expect(
|
||||
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
// Why this decides the cluster: 200 MB available commit is only a REFUSAL when
|
||||
// the pagefile cannot grow, which is what the volume's free space says.
|
||||
it('reports swap-volume free space so low commit can be told from refused commit', async () => {
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
|
||||
await samplePreGoneSystemMemory(Date.now() - 5_000)
|
||||
|
||||
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
|
||||
|
||||
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(120)
|
||||
// Which volume was measured: Windows only names the DEFAULT pagefile drive.
|
||||
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
|
||||
})
|
||||
|
||||
it('omits swap-volume free space on Linux, where swap cannot grow into free disk', async () => {
|
||||
// Linux swap is a fixed partition, a fixed-size swapfile, or zram; reporting
|
||||
// root-fs free space next to SwapFreeMB 0 would read as headroom that is not there.
|
||||
setSwapVolumeFreeSpaceReaderForTest(null)
|
||||
|
||||
await expect(readSwapVolumeFreeSpace('linux')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('labels the reading with the pressure verdict the platform can actually give', () => {
|
||||
// Windows available commit is only a REFUSAL when the pagefile cannot grow,
|
||||
// which nothing here proves, so no label may read as that verdict.
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
const windowsCommit = getSystemMemoryDetails('win32')
|
||||
expect(windowsCommit.systemMemoryPressureSignal).toBe('available-commit-unqualified')
|
||||
expect(
|
||||
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32')
|
||||
.systemMemoryPressureSignal
|
||||
).toBe('available-commit-volume-cotimed')
|
||||
// A volume number from a different moment describes a different machine.
|
||||
expect(
|
||||
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32', false)
|
||||
.systemMemoryPressureSignal
|
||||
).toBe('available-commit-unqualified')
|
||||
|
||||
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, free: 400 * 1024 }))
|
||||
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('none')
|
||||
|
||||
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, available: 900 * 1024 }))
|
||||
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('mem-available')
|
||||
|
||||
// darwin free/fileBacked/purgeable answer reclaimability, never pressure.
|
||||
setSystemMemoryInfoReaderForTest(() => ({
|
||||
total: 16_000 * 1024,
|
||||
free: 272 * 1024,
|
||||
fileBacked: 2_694 * 1024,
|
||||
purgeable: 0
|
||||
}))
|
||||
expect(getSystemMemoryDetails('darwin').systemMemoryPressureSignal).toBe('none')
|
||||
})
|
||||
|
||||
// Why this and not the volume number: the branch's own repro needed a pagefile
|
||||
// that CANNOT grow to kill anything, and neither the pagefile maximum nor its
|
||||
// drive is readable here — `swapVolumeAnchor` measures SystemRoot's volume,
|
||||
// which a relocated pagefile does not live on.
|
||||
it('never reads free disk as proof the pagefile could have grown', () => {
|
||||
setSystemMemoryInfoReaderForTest(() => FIXED_PAGEFILE_UNDER_PRESSURE)
|
||||
const fixedPagefile = withSwapVolumeFreeSpace(
|
||||
getSystemMemoryDetails('win32'),
|
||||
{ freeMB: 812_000, volume: 'C:' },
|
||||
'win32'
|
||||
)
|
||||
// 180 MB of commit beside 812 GB of free disk: co-timed, and still not a
|
||||
// verdict — reading it as "the pagefile had room" is the opposite conclusion.
|
||||
expect(fixedPagefile.systemMemoryPressureSignal).toBe('available-commit-volume-cotimed')
|
||||
|
||||
// The one decisive win32 case: commit limit at or below RAM means there is
|
||||
// no pagefile behind it, so the floor cannot heal however empty the disk is.
|
||||
setSystemMemoryInfoReaderForTest(() => NO_PAGEFILE_UNDER_PRESSURE)
|
||||
expect(
|
||||
withSwapVolumeFreeSpace(
|
||||
getSystemMemoryDetails('win32'),
|
||||
{ freeMB: 812_000, volume: 'C:' },
|
||||
'win32'
|
||||
).systemMemoryPressureSignal
|
||||
).toBe('available-commit-hard-capped')
|
||||
})
|
||||
|
||||
// Why the verdict and not just the field: a statfs issued on a healthy host at
|
||||
// t=0 that resolves 20 s into a commit storm prints "200 MB commit, 40 GB of
|
||||
// pagefile headroom" — which reads as NOT a commit refusal, the opposite
|
||||
// conclusion, under the branch's most confident label.
|
||||
it('will not let a statfs that outlived its tick qualify the win32 commit verdict', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.useFakeTimers()
|
||||
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
|
||||
try {
|
||||
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
|
||||
setSwapVolumeFreeSpaceReaderForTest(
|
||||
() =>
|
||||
new Promise<SwapVolumeFreeSpace>((resolve) => {
|
||||
resolveVolume = resolve
|
||||
})
|
||||
)
|
||||
void samplePreGoneSystemMemory(0)
|
||||
|
||||
// The storm arrives; the in-flight latch makes every tick skip the merge,
|
||||
// so the pending statfs is as old as the tick that STARTED it.
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
await samplePreGoneSystemMemory(10_000)
|
||||
await samplePreGoneSystemMemory(20_000)
|
||||
|
||||
resolveVolume({ freeMB: 40_000, volume: 'C:' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
vi.setSystemTime(20_000)
|
||||
const stale = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
expect(stale.systemMemoryPreGoneSwapFreeMB).toBe(200)
|
||||
// The pre-storm volume number still ships — but carrying its own age, and
|
||||
// without promoting the verdict the analyst reads.
|
||||
expect(stale.systemMemoryPreGoneSwapVolumeFreeMB).toBe(40_000)
|
||||
expect(stale.systemMemoryPreGoneSampleAgeMs).toBe(0)
|
||||
expect(stale.systemMemoryPreGoneSwapVolumeAgeMs).toBe(20_000)
|
||||
expect(stale.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
|
||||
|
||||
// The next tick's statfs answers on its own tick, so it qualifies again.
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 900, volume: 'C:' }))
|
||||
await samplePreGoneSystemMemory(30_000)
|
||||
vi.setSystemTime(30_000)
|
||||
const fresh = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
expect(fresh.systemMemoryPreGoneSwapVolumeFreeMB).toBe(900)
|
||||
expect(fresh.systemMemoryPreGoneSwapVolumeAgeMs).toBe(0)
|
||||
expect(fresh.systemMemoryPreGonePressureSignal).toBe('available-commit-volume-cotimed')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
// Round 5: sample identity alone could not see these ticks. A host read that
|
||||
// returns nothing leaves the sample object in place, so `sample === issuedFor`
|
||||
// still held 25 s and two ticks later and the statfs re-qualified the verdict.
|
||||
it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.useFakeTimers()
|
||||
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
|
||||
try {
|
||||
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
|
||||
setSwapVolumeFreeSpaceReaderForTest(
|
||||
() =>
|
||||
new Promise<SwapVolumeFreeSpace>((resolve) => {
|
||||
resolveVolume = resolve
|
||||
})
|
||||
)
|
||||
void samplePreGoneSystemMemory(0)
|
||||
|
||||
// GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased.
|
||||
setSystemMemoryInfoReaderForTest(() => null)
|
||||
await samplePreGoneSystemMemory(10_000)
|
||||
await samplePreGoneSystemMemory(20_000)
|
||||
|
||||
resolveVolume({ freeMB: 40_000, volume: 'C:' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
vi.setSystemTime(25_000)
|
||||
const details = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
// 25 s of lag: the label must not say co-timed beside that age.
|
||||
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000)
|
||||
expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const readHostMemory = vi.fn(() => UNDER_COMMIT_PRESSURE)
|
||||
setSystemMemoryInfoReaderForTest(readHostMemory)
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
|
||||
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
try {
|
||||
startPreGoneCrashSampling()
|
||||
|
||||
// Literal millisecond values: asserting the constants against themselves
|
||||
// would let a cadence regression through, and 37 s of staleness is the bug.
|
||||
expect(setIntervalSpy.mock.calls.map(([, ms]) => ms)).toEqual([60_000, 10_000])
|
||||
for (const { value } of setIntervalSpy.mock.results) {
|
||||
expect((value as NodeJS.Timeout).hasRef()).toBe(false)
|
||||
}
|
||||
expect(readHostMemory).toHaveBeenCalledTimes(1)
|
||||
|
||||
readHostMemory.mockReturnValue(AFTER_THE_CORPSE_RELEASED)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
// One host tick, no extra metric sweep: the two samplers run independently.
|
||||
expect(readHostMemory).toHaveBeenCalledTimes(2)
|
||||
expect(appMetricsMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const details = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
|
||||
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(2_900)
|
||||
} finally {
|
||||
setIntervalSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('commits the host reading without waiting on the swap-volume statfs', async () => {
|
||||
// Why: statfs is slowest during the paging storm this sampler targets, and
|
||||
// a hung volume must not stall or silently skip host sampling.
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {}))
|
||||
|
||||
void samplePreGoneSystemMemory(Date.now() - 5_000)
|
||||
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(200)
|
||||
|
||||
// A second tick still refreshes the reading while that statfs hangs.
|
||||
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
|
||||
void samplePreGoneSystemMemory(Date.now())
|
||||
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(2_900)
|
||||
})
|
||||
|
||||
it('publishes no pre-gone host keys when every memory field failed to read', async () => {
|
||||
// Why not "no keys at all": the reading always carries its signal label, so a
|
||||
// committed empty one would ship an age and a volume number with no memory
|
||||
// numbers beside them — a disk-free figure standing in for a host reading.
|
||||
setSystemMemoryInfoReaderForTest(() => ({ total: Number.NaN, free: undefined }))
|
||||
await samplePreGoneSystemMemory(Date.now())
|
||||
|
||||
const details = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
|
||||
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
|
||||
})
|
||||
|
||||
it('carries the last volume reading forward, aged, instead of dropping it', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 42, volume: 'C:' }))
|
||||
await samplePreGoneSystemMemory(0)
|
||||
|
||||
// The next tick's statfs hangs — during the paging storm this targets, that
|
||||
// is the normal case — so the tick has no volume reading of its own, and
|
||||
// the sample that replaces the last one would otherwise drop the field.
|
||||
setSwapVolumeFreeSpaceReaderForTest(() => new Promise<never>(() => {}))
|
||||
void samplePreGoneSystemMemory(10_000)
|
||||
vi.setSystemTime(10_000)
|
||||
|
||||
const details = buildProcessGoneCrashDetails({}, 'renderer')
|
||||
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(42)
|
||||
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
|
||||
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
|
||||
// Carried, not re-read: it ships at its real age, never as a fresh number.
|
||||
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(10_000)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a failed host read from erasing the process-metric sample', async () => {
|
||||
samplePreGoneProcessMetrics(Date.now() - 5_000)
|
||||
setSystemMemoryInfoReaderForTest(() => {
|
||||
throw new Error('getSystemMemoryInfo unavailable')
|
||||
})
|
||||
await samplePreGoneSystemMemory(Date.now() - 5_000)
|
||||
setSystemMemoryInfoReaderForTest(null)
|
||||
|
||||
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
|
||||
|
||||
expect(details.processMetricsPreGoneRendererWorkingSetMB).toBe(400)
|
||||
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
|
||||
import { readSwapVolumeFreeSpace } from './swap-volume-free-space'
|
||||
import {
|
||||
getSystemMemoryDetails,
|
||||
SYSTEM_MEMORY_KEY_PREFIX,
|
||||
withSwapVolumeFreeSpace
|
||||
} from './system-memory-details'
|
||||
|
||||
// ─── Pre-gone host memory sampling ──────────────────────────────────
|
||||
// Why sample at all: the gone-time host read lands after the corpse released
|
||||
// its pages, so it reports a healthier machine than the one that refused the
|
||||
// allocation.
|
||||
// Why 10 s and not the 60 s process-metrics cadence: at 60 s, four of five
|
||||
// field OOMs carried a ~37 s old host reading — far too stale to see a
|
||||
// transient commit refusal. A refusal shorter than the interval stays
|
||||
// invisible; no cadence fixes that.
|
||||
|
||||
export const PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS = 10_000
|
||||
|
||||
type CrashReportDetails = Record<string, CrashReportDetailValue>
|
||||
|
||||
type PreGoneSystemMemorySample = {
|
||||
details: CrashReportDetails
|
||||
sampledAtMs: number
|
||||
/** Tick that ISSUED the statfs now merged in — never the tick it resolved on. */
|
||||
swapVolumeSampledAtMs?: number
|
||||
}
|
||||
|
||||
let preGoneSample: PreGoneSystemMemorySample | null = null
|
||||
let preGoneTimer: ReturnType<typeof setInterval> | null = null
|
||||
let swapVolumeReadInFlight = false
|
||||
let samplingGeneration = 0
|
||||
let sampleTick = 0
|
||||
|
||||
const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`
|
||||
|
||||
/**
|
||||
* Carries the last volume reading onto the sample that replaces its own.
|
||||
*
|
||||
* Why: a statfs slower than one tick would otherwise make the field vanish from
|
||||
* the reports it exists for — the next tick replaces the sample wholesale, and
|
||||
* the in-flight latch keeps intervening ticks from merging anything. It ships
|
||||
* with its own (now larger) age and, not being co-timed, never names the label.
|
||||
*/
|
||||
function withCarriedSwapVolume(sample: PreGoneSystemMemorySample): PreGoneSystemMemorySample {
|
||||
const previous = preGoneSample
|
||||
if (!previous || previous.swapVolumeSampledAtMs === undefined) {
|
||||
return sample
|
||||
}
|
||||
const freeMB = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]
|
||||
const volume = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]
|
||||
if (typeof freeMB !== 'number' || typeof volume !== 'string') {
|
||||
return sample
|
||||
}
|
||||
return {
|
||||
...sample,
|
||||
details: withSwapVolumeFreeSpace(sample.details, { freeMB, volume }, process.platform, false),
|
||||
swapVolumeSampledAtMs: previous.swapVolumeSampledAtMs
|
||||
}
|
||||
}
|
||||
|
||||
function commitHostMemorySample(nowMs: number): boolean {
|
||||
try {
|
||||
const details = getSystemMemoryDetails()
|
||||
// Why not `length === 0`: the signal label is appended unconditionally, so a
|
||||
// reading that resolved no memory field at all still arrives with one key.
|
||||
if (!Object.keys(details).some((key) => key !== PRESSURE_SIGNAL_KEY)) {
|
||||
return false
|
||||
}
|
||||
preGoneSample = withCarriedSwapVolume({ details, sampledAtMs: nowMs })
|
||||
return true
|
||||
} catch {
|
||||
// Why: a failed read must not erase the previous good sample.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise<void> {
|
||||
if (swapVolumeReadInFlight) {
|
||||
return
|
||||
}
|
||||
swapVolumeReadInFlight = true
|
||||
const generation = samplingGeneration
|
||||
const issuedFor = preGoneSample
|
||||
try {
|
||||
const volume = await readSwapVolumeFreeSpace()
|
||||
if (volume && preGoneSample && generation === samplingGeneration) {
|
||||
// Why only its own tick qualifies: a statfs that outlived its tick carries a
|
||||
// pre-storm volume number, and the latch makes that lag unbounded. It still
|
||||
// ships beside its age, but it may not decide the verdict.
|
||||
// Why the tick counter and not sample identity: a tick whose host read fails
|
||||
// leaves the sample object in place, so identity alone reads as co-timed.
|
||||
const coTimed = issuedOnTick === sampleTick
|
||||
preGoneSample = {
|
||||
...preGoneSample,
|
||||
details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed),
|
||||
swapVolumeSampledAtMs: issuedFor?.sampledAtMs
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Why: the memory reading is already committed and stands on its own.
|
||||
} finally {
|
||||
swapVolumeReadInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Promise<void> {
|
||||
// Why commit before awaiting: the volume read is a statfs, and under the very
|
||||
// paging storm this targets it is slowest — it must never delay, or (via an
|
||||
// in-flight latch) skip, the cheap synchronous host reading.
|
||||
const tick = ++sampleTick
|
||||
if (!commitHostMemorySample(nowMs)) {
|
||||
return
|
||||
}
|
||||
await mergeSwapVolumeFreeSpace(tick)
|
||||
}
|
||||
|
||||
export function startPreGoneSystemMemorySampling(
|
||||
intervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
|
||||
): void {
|
||||
if (preGoneTimer) {
|
||||
return
|
||||
}
|
||||
void samplePreGoneSystemMemory()
|
||||
preGoneTimer = setInterval(() => void samplePreGoneSystemMemory(), intervalMs)
|
||||
preGoneTimer.unref?.()
|
||||
}
|
||||
|
||||
export function resetPreGoneSystemMemorySamplingForTest(): void {
|
||||
if (preGoneTimer) {
|
||||
clearInterval(preGoneTimer)
|
||||
}
|
||||
preGoneTimer = null
|
||||
preGoneSample = null
|
||||
swapVolumeReadInFlight = false
|
||||
// Why bump: an already-awaited volume read must not repopulate a reset sample.
|
||||
samplingGeneration += 1
|
||||
}
|
||||
|
||||
/** Keyed as `systemMemoryPreGone*` so a scan over the `systemMemory` family sees both reads. */
|
||||
export function preGoneSystemMemoryDetails(nowMs: number): CrashReportDetails {
|
||||
if (!preGoneSample) {
|
||||
return {}
|
||||
}
|
||||
const details: CrashReportDetails = {
|
||||
[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]: Math.max(
|
||||
0,
|
||||
nowMs - preGoneSample.sampledAtMs
|
||||
)
|
||||
}
|
||||
// Why its own age: the volume read resolves out of band, so it can be older
|
||||
// than the memory reading printed beside it, and that gap must be readable.
|
||||
if (preGoneSample.swapVolumeSampledAtMs !== undefined) {
|
||||
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapVolumeAgeMs`] = Math.max(
|
||||
0,
|
||||
nowMs - preGoneSample.swapVolumeSampledAtMs
|
||||
)
|
||||
}
|
||||
for (const [key, value] of Object.entries(preGoneSample.details)) {
|
||||
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGone${key.slice(SYSTEM_MEMORY_KEY_PREFIX.length)}`] =
|
||||
value
|
||||
}
|
||||
return details
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildProcessGoneCrashDetails,
|
||||
collectProcessGoneMetricDetails,
|
||||
resetPreGoneProcessMetricsSamplingForTest,
|
||||
resetPreGoneCrashSamplingForTest,
|
||||
samplePreGoneProcessMetrics,
|
||||
startPreGoneProcessMetricsSampling
|
||||
startPreGoneCrashSampling
|
||||
} from './process-gone-diagnostics'
|
||||
import { setSystemMemoryInfoReaderForTest } from './gone-time-system-memory'
|
||||
import { setSystemMemoryInfoReaderForTest } from './system-memory-details'
|
||||
|
||||
type MetricFixture = {
|
||||
pid?: number
|
||||
@@ -27,7 +27,7 @@ vi.mock('electron', () => ({
|
||||
|
||||
describe('process gone diagnostics', () => {
|
||||
beforeEach(() => {
|
||||
resetPreGoneProcessMetricsSamplingForTest()
|
||||
resetPreGoneCrashSamplingForTest()
|
||||
setSystemMemoryInfoReaderForTest(null)
|
||||
})
|
||||
|
||||
@@ -141,8 +141,8 @@ describe('process gone diagnostics', () => {
|
||||
appMetricsMock.mockReturnValue([
|
||||
{ pid: 30, type: 'Tab', memory: { workingSetSize: 1024 * 100 } }
|
||||
])
|
||||
startPreGoneProcessMetricsSampling(1_000)
|
||||
startPreGoneProcessMetricsSampling(1_000)
|
||||
startPreGoneCrashSampling(1_000)
|
||||
startPreGoneCrashSampling(1_000)
|
||||
|
||||
// A crash inside the first interval already has a sample to draw from.
|
||||
expect(buildProcessGoneCrashDetails({}, 'renderer')).toMatchObject({
|
||||
@@ -582,12 +582,12 @@ describe('process gone diagnostics', () => {
|
||||
it("arms an unref'd interval so sampling never holds the event loop open", () => {
|
||||
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
try {
|
||||
startPreGoneProcessMetricsSampling(60_000)
|
||||
startPreGoneCrashSampling(60_000)
|
||||
const timer = setIntervalSpy.mock.results[0]?.value as NodeJS.Timeout
|
||||
expect(timer.hasRef()).toBe(false)
|
||||
} finally {
|
||||
setIntervalSpy.mockRestore()
|
||||
resetPreGoneProcessMetricsSamplingForTest()
|
||||
resetPreGoneCrashSamplingForTest()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -641,7 +641,7 @@ describe('process gone diagnostics', () => {
|
||||
expect(details.systemMemoryTotalMB).toBe(16_384)
|
||||
})
|
||||
|
||||
it('samples system memory at gone time but never into the pre-gone snapshot', () => {
|
||||
it('samples system memory at gone time but never into the processMetrics family', () => {
|
||||
appMetricsMock.mockReturnValue([{ pid: 1, type: 'Browser', memory: { workingSetSize: 0 } }])
|
||||
samplePreGoneProcessMetrics()
|
||||
setSystemMemoryInfoReaderForTest(() => ({
|
||||
@@ -658,7 +658,9 @@ describe('process gone diagnostics', () => {
|
||||
systemMemorySwapTotalMB: 8_192,
|
||||
systemMemorySwapFreeMB: 40
|
||||
})
|
||||
expect(details.processMetricsPreGoneSystemMemoryTotalMB).toBeUndefined()
|
||||
expect(
|
||||
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves records unflagged when the crashed bucket is still populated', () => {
|
||||
|
||||
@@ -3,7 +3,13 @@ import {
|
||||
sanitizeCrashReportDetails,
|
||||
type CrashReportDetailValue
|
||||
} from '../../shared/crash-reporting'
|
||||
import { getSystemMemoryAtGoneDetails, memoryKBFieldMB } from './gone-time-system-memory'
|
||||
import { getSystemMemoryDetails, memoryKBFieldMB } from './system-memory-details'
|
||||
import {
|
||||
PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS,
|
||||
preGoneSystemMemoryDetails,
|
||||
resetPreGoneSystemMemorySamplingForTest,
|
||||
startPreGoneSystemMemorySampling
|
||||
} from './pre-gone-host-memory'
|
||||
|
||||
type ProcessMetricLike = {
|
||||
pid?: unknown
|
||||
@@ -204,8 +210,9 @@ export function samplePreGoneProcessMetrics(nowMs: number = Date.now()): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function startPreGoneProcessMetricsSampling(
|
||||
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS
|
||||
export function startPreGoneCrashSampling(
|
||||
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS,
|
||||
systemMemoryIntervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
|
||||
): void {
|
||||
if (preGoneSampleTimer) {
|
||||
return
|
||||
@@ -213,14 +220,16 @@ export function startPreGoneProcessMetricsSampling(
|
||||
samplePreGoneProcessMetrics()
|
||||
preGoneSampleTimer = setInterval(() => samplePreGoneProcessMetrics(), intervalMs)
|
||||
preGoneSampleTimer.unref?.()
|
||||
startPreGoneSystemMemorySampling(systemMemoryIntervalMs)
|
||||
}
|
||||
|
||||
export function resetPreGoneProcessMetricsSamplingForTest(): void {
|
||||
export function resetPreGoneCrashSamplingForTest(): void {
|
||||
if (preGoneSampleTimer) {
|
||||
clearInterval(preGoneSampleTimer)
|
||||
}
|
||||
preGoneSampleTimer = null
|
||||
preGoneSample = null
|
||||
resetPreGoneSystemMemorySamplingForTest()
|
||||
}
|
||||
|
||||
const PROCESS_METRICS_KEY_PREFIX = 'processMetrics'
|
||||
@@ -271,7 +280,7 @@ export function buildProcessGoneCrashDetails(
|
||||
const crashDetails: CrashReportDetails = {
|
||||
...sanitizedDetails,
|
||||
...liveMetricDetails,
|
||||
...getSystemMemoryAtGoneDetails()
|
||||
...getSystemMemoryDetails()
|
||||
}
|
||||
// Why: with the crasher gone, Largest names a survivor — flag that so the
|
||||
// live buckets are read as "everyone else", not as the crashed process.
|
||||
@@ -290,8 +299,10 @@ export function buildProcessGoneCrashDetails(
|
||||
if (liveMetricDetails[crashedBucketCountKey] === 0 || sampledSameBucketProcessVanished) {
|
||||
crashDetails.processMetricsCrashedProcessAbsent = true
|
||||
}
|
||||
const nowMs = Date.now()
|
||||
if (preGoneSample) {
|
||||
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, Date.now()))
|
||||
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, nowMs))
|
||||
}
|
||||
Object.assign(crashDetails, preGoneSystemMemoryDetails(nowMs))
|
||||
return crashDetails
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { statfs } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
// Why: a system-managed Windows pagefile — and a macOS swapfile — only grows
|
||||
// into free space on its own volume, so low available commit is a REFUSED
|
||||
// allocation only when that volume is full too. Linux is excluded on purpose:
|
||||
// its swap is a fixed partition, a fixed-size swapfile, or zram, none of which
|
||||
// grow into root-fs free space, so the number would read as headroom that
|
||||
// cannot exist. The measured volume ships alongside because Windows only names
|
||||
// the DEFAULT pagefile drive; a relocated pagefile lives elsewhere.
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
export type SwapVolumeFreeSpace = {
|
||||
freeMB: number
|
||||
/** Which volume was measured, separator-trimmed so redaction sees no path. */
|
||||
volume: string
|
||||
}
|
||||
|
||||
type SwapVolumeFreeSpaceReader = (
|
||||
platform: NodeJS.Platform
|
||||
) => Promise<SwapVolumeFreeSpace | undefined>
|
||||
|
||||
function swapVolumeAnchor(platform: NodeJS.Platform): string | undefined {
|
||||
if (platform === 'win32') {
|
||||
const anchor = process.env.SystemRoot || process.env.SystemDrive
|
||||
return anchor ? path.parse(anchor).root || anchor : undefined
|
||||
}
|
||||
return platform === 'darwin' ? path.sep : undefined
|
||||
}
|
||||
|
||||
function volumeLabel(root: string): string {
|
||||
const trimmed = root.replace(/[\\/]+$/, '')
|
||||
return trimmed.length > 0 ? trimmed : root
|
||||
}
|
||||
|
||||
async function statfsSwapVolumeFreeSpace(
|
||||
platform: NodeJS.Platform
|
||||
): Promise<SwapVolumeFreeSpace | undefined> {
|
||||
const root = swapVolumeAnchor(platform)
|
||||
if (!root) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const stats = await statfs(root)
|
||||
const bytes = Number(stats.bsize) * Number(stats.bavail)
|
||||
return Number.isFinite(bytes)
|
||||
? { freeMB: Math.round(Math.max(0, bytes) / BYTES_PER_MB), volume: volumeLabel(root) }
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
let swapVolumeFreeSpaceReader: SwapVolumeFreeSpaceReader = statfsSwapVolumeFreeSpace
|
||||
|
||||
export function setSwapVolumeFreeSpaceReaderForTest(
|
||||
reader: SwapVolumeFreeSpaceReader | null
|
||||
): void {
|
||||
swapVolumeFreeSpaceReader = reader ?? statfsSwapVolumeFreeSpace
|
||||
}
|
||||
|
||||
export function readSwapVolumeFreeSpace(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<SwapVolumeFreeSpace | undefined> {
|
||||
return swapVolumeFreeSpaceReader(platform)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
|
||||
import type { SwapVolumeFreeSpace } from './swap-volume-free-space'
|
||||
|
||||
// ─── Host system memory for crash reports ───────────────────────────
|
||||
// Why: the system outlives the crashed process, so this IS sampleable at
|
||||
// process-gone — it separates "renderer grew huge" from "machine out of
|
||||
// memory/commit", which the per-process buckets alone cannot. The gone-time
|
||||
// caller reads AFTER the corpse returned its pages, so free/swapFree read
|
||||
// healthier than at kill time; the pre-gone sampler carries a live reading past
|
||||
// that.
|
||||
// Every reading is labelled `systemMemoryPressureSignal` so no report can be
|
||||
// read as a pressure verdict the platform never gave:
|
||||
// win32 — swapFree is MEMORYSTATUSEX.ullAvailPageFile, i.e. available
|
||||
// COMMIT, which pagefile growth can heal (a 127 MB commit floor healed to
|
||||
// 2029 MB mid-hold on the win-lowspec repro, killing nothing). Free space on
|
||||
// the swap volume does NOT establish that it could: a fixed-size or disabled
|
||||
// pagefile grows into no amount of empty disk, its maximum is unreadable
|
||||
// here (needs a registry read), and the measured volume is only the DEFAULT
|
||||
// pagefile drive. So a co-timed volume reading is context beside the commit
|
||||
// number — `available-commit-volume-cotimed` — never a verdict. The one
|
||||
// decisive win32 case is a commit limit at or below RAM: no pagefile exists
|
||||
// to grow, so the floor cannot heal (`available-commit-hard-capped`).
|
||||
// linux — MemAvailable is the real signal; MemFree is not (it excludes page
|
||||
// cache and other reclaimable memory).
|
||||
// darwin — none. `free` stays low on healthy machines and
|
||||
// fileBacked/purgeable are only a reclaimability proxy. The real signal
|
||||
// needs `memory_pressure -Q`; Orca's reader for it
|
||||
// (src/main/memory/host-memory.ts) is on-demand, and spawning a subprocess
|
||||
// on a 10 s app-lifetime timer costs more than the gap it closes.
|
||||
|
||||
type CrashReportDetails = Record<string, CrashReportDetailValue>
|
||||
|
||||
export const SYSTEM_MEMORY_KEY_PREFIX = 'systemMemory'
|
||||
|
||||
export function memoryKBFieldMB(value: unknown): number | undefined {
|
||||
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
|
||||
}
|
||||
|
||||
type SystemMemoryInfoLike = {
|
||||
total?: unknown
|
||||
free?: unknown
|
||||
available?: unknown
|
||||
swapTotal?: unknown
|
||||
swapFree?: unknown
|
||||
fileBacked?: unknown
|
||||
purgeable?: unknown
|
||||
}
|
||||
|
||||
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
|
||||
|
||||
/** How far this reading may be read as a "was the host under pressure" verdict. */
|
||||
export type SystemMemoryPressureSignal =
|
||||
| 'available-commit-hard-capped'
|
||||
| 'available-commit-volume-cotimed'
|
||||
| 'available-commit-unqualified'
|
||||
| 'mem-available'
|
||||
| 'none'
|
||||
|
||||
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
|
||||
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
|
||||
.getSystemMemoryInfo
|
||||
if (typeof read !== 'function') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return read.call(process)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
|
||||
|
||||
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
|
||||
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
|
||||
}
|
||||
|
||||
function numericDetail(details: CrashReportDetails, suffix: string): number | undefined {
|
||||
const value = details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`]
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/** Windows commit limit = RAM + pagefile, so a limit at or below RAM has no pagefile behind it. */
|
||||
function pagefileBacksCommit(details: CrashReportDetails): boolean | undefined {
|
||||
const total = numericDetail(details, 'TotalMB')
|
||||
const swapTotal = numericDetail(details, 'SwapTotalMB')
|
||||
return total === undefined || swapTotal === undefined ? undefined : swapTotal > total
|
||||
}
|
||||
|
||||
function pressureSignal(
|
||||
platform: NodeJS.Platform,
|
||||
details: CrashReportDetails,
|
||||
volumeCoTimed = true
|
||||
): SystemMemoryPressureSignal {
|
||||
if (platform === 'win32' && `${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB` in details) {
|
||||
if (pagefileBacksCommit(details) === false) {
|
||||
return 'available-commit-hard-capped'
|
||||
}
|
||||
return volumeCoTimed && `${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB` in details
|
||||
? 'available-commit-volume-cotimed'
|
||||
: 'available-commit-unqualified'
|
||||
}
|
||||
if (platform === 'linux' && `${SYSTEM_MEMORY_KEY_PREFIX}AvailableMB` in details) {
|
||||
return 'mem-available'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
export function getSystemMemoryDetails(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): CrashReportDetails {
|
||||
const info = systemMemoryInfoReader()
|
||||
if (!info) {
|
||||
return {}
|
||||
}
|
||||
const details: CrashReportDetails = {}
|
||||
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
|
||||
['total', 'TotalMB'],
|
||||
['free', 'FreeMB'],
|
||||
['available', 'AvailableMB'],
|
||||
['swapTotal', 'SwapTotalMB'],
|
||||
['swapFree', 'SwapFreeMB'],
|
||||
['fileBacked', 'FileBackedMB'],
|
||||
['purgeable', 'PurgeableMB']
|
||||
]
|
||||
for (const [field, suffix] of fields) {
|
||||
const mb = memoryKBFieldMB(info[field])
|
||||
if (mb !== undefined) {
|
||||
details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] = mb
|
||||
}
|
||||
}
|
||||
details[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, details)
|
||||
return details
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the statfs-derived volume datum, which needs an await and so is only
|
||||
* reachable from the periodic sampler, and relabels the reading it sits beside.
|
||||
*
|
||||
* `coTimed` false means the statfs outlived the tick that issued it, so this
|
||||
* volume number and the commit number beside it describe different moments —
|
||||
* during a pagefile-growth storm that is exactly when they diverge, and a
|
||||
* pre-storm 40 GB printed next to 200 MB of commit reads as "the pagefile had
|
||||
* room", the opposite conclusion. The datum still ships (with its own age), but
|
||||
* only a co-timed one is named in the label.
|
||||
*/
|
||||
export function withSwapVolumeFreeSpace(
|
||||
details: CrashReportDetails,
|
||||
volume: SwapVolumeFreeSpace,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
coTimed = true
|
||||
): CrashReportDetails {
|
||||
const merged: CrashReportDetails = {
|
||||
...details,
|
||||
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]: volume.freeMB,
|
||||
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]: volume.volume
|
||||
}
|
||||
merged[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, merged, coTimed)
|
||||
return merged
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
prepareWslLinkedWorktreeGitRouting
|
||||
} from '../wsl-linked-worktree-git-routing'
|
||||
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
|
||||
import { annotateWslHostFailure } from './wsl-host-failure'
|
||||
import type { GitAdmissionTier, GitExecOptions } from './git-exec-options'
|
||||
import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture'
|
||||
import {
|
||||
@@ -96,7 +97,7 @@ async function gitExecFileAsyncUnlocked(
|
||||
? {}
|
||||
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
|
||||
}
|
||||
return options.terminationBarrier
|
||||
const captured = options.terminationBarrier
|
||||
? execFileCaptureToTermination(
|
||||
command.binary,
|
||||
command.args,
|
||||
@@ -104,6 +105,10 @@ async function gitExecFileAsyncUnlocked(
|
||||
command.termination
|
||||
)
|
||||
: execFileCapture(command.binary, command.args, captureOptions)
|
||||
// Why: a dead WSL distro fails with an empty stderr, so the span would carry no cause at all.
|
||||
return captured.catch((error: unknown) => {
|
||||
throw annotateWslHostFailure(error, command)
|
||||
})
|
||||
}
|
||||
const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => {
|
||||
let result: { stdout: string | Buffer; stderr: string | Buffer }
|
||||
|
||||
@@ -63,6 +63,11 @@ export function nonInteractiveGitEnv(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const next = promptGuardGitEnv(env, platform)
|
||||
if (platform === 'win32') {
|
||||
// Why: without it wsl.exe writes its OWN failures ("no distribution with the supplied name") as
|
||||
// UTF-16LE (#9010), which is how a dead distro reached telemetry as an error with no text.
|
||||
next.WSL_UTF8 = '1'
|
||||
}
|
||||
if (!next.GIT_SSH_COMMAND) {
|
||||
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
|
||||
if (platform === 'win32') {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: vi.fn(),
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
vi.mock('../../observability/instrumentation', () => ({
|
||||
withGitSpan: (_attributes: unknown, run: (span: unknown) => unknown) =>
|
||||
run({ setAttribute: () => {} })
|
||||
}))
|
||||
vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
|
||||
|
||||
import { gitExecFileAsync } from '../runner'
|
||||
import { _resetGitAdmissionForTests } from './git-subprocess-admission'
|
||||
import { nonInteractiveGitEnv } from './git-process-env'
|
||||
import { annotateWslHostFailure, readWslHostFailureDiagnostic } from './wsl-host-failure'
|
||||
import type { ResolvedCommand } from './wsl-command-resolution'
|
||||
|
||||
const WSL_COMMAND: ResolvedCommand = {
|
||||
binary: 'wsl.exe',
|
||||
args: ['-d', 'kali-linux', '--exec', 'sh', '-lc', 'git worktree list'],
|
||||
cwd: 'C:\\Users\\paulius',
|
||||
wsl: { distro: 'kali-linux', linuxPath: '/home/paulius/bugbounty' },
|
||||
wslMode: 'login-shell'
|
||||
}
|
||||
|
||||
const WSL_DIAGNOSTIC =
|
||||
'There is no distribution with the supplied name.\r\nError code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\r\n'
|
||||
|
||||
/** wsl.exe without WSL_UTF8 writes its own diagnostic as UTF-16LE, which reaches Node as NUL-riddled text. */
|
||||
function asUtf16Mojibake(text: string): string {
|
||||
return [...text].map((character) => `${character}\u0000`).join('')
|
||||
}
|
||||
|
||||
function hostFailure(stdout: string): Error {
|
||||
return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), {
|
||||
code: 4294967295,
|
||||
stdout,
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: original })
|
||||
}
|
||||
}
|
||||
|
||||
describe('wsl.exe host failure classification', () => {
|
||||
it('reads the diagnostic wsl.exe left on stdout, including UTF-16 output', () => {
|
||||
expect(readWslHostFailureDiagnostic(hostFailure(WSL_DIAGNOSTIC), WSL_COMMAND)).toContain(
|
||||
'Wsl/Service/WSL_E_DISTRO_NOT_FOUND'
|
||||
)
|
||||
expect(
|
||||
readWslHostFailureDiagnostic(hostFailure(asUtf16Mojibake(WSL_DIAGNOSTIC)), WSL_COMMAND)
|
||||
).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('leaves a guest failure and a non-WSL command alone', () => {
|
||||
const guestFailure = Object.assign(new Error('Command failed'), {
|
||||
code: 1,
|
||||
stdout: '',
|
||||
stderr: 'fatal: not a git repository\n'
|
||||
})
|
||||
expect(readWslHostFailureDiagnostic(guestFailure, WSL_COMMAND)).toBeNull()
|
||||
// Same exit code, but wsl.exe was never involved.
|
||||
expect(
|
||||
readWslHostFailureDiagnostic(hostFailure(WSL_DIAGNOSTIC), {
|
||||
binary: 'git',
|
||||
args: ['status'],
|
||||
cwd: '/repo',
|
||||
wsl: null,
|
||||
wslMode: null
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('moves the diagnostic into the message the span records', () => {
|
||||
const error = annotateWslHostFailure(hostFailure(WSL_DIAGNOSTIC), WSL_COMMAND) as Error & {
|
||||
wslHostFailure?: boolean
|
||||
wslDistro?: string
|
||||
code?: number
|
||||
}
|
||||
expect(error.message).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
|
||||
expect(error.message).toContain('kali-linux')
|
||||
expect(error.wslHostFailure).toBe(true)
|
||||
expect(error.wslDistro).toBe('kali-linux')
|
||||
// The original failure detail must survive for callers that classify on it.
|
||||
expect(error.code).toBe(4294967295)
|
||||
expect(error.message).toContain('Command failed: wsl.exe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL-routed git subprocess', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetGitAdmissionForTests()
|
||||
})
|
||||
|
||||
it('sets WSL_UTF8 so wsl.exe explains itself in UTF-8', () => {
|
||||
expect(nonInteractiveGitEnv({}, 'win32').WSL_UTF8).toBe('1')
|
||||
expect(nonInteractiveGitEnv({}, 'darwin').WSL_UTF8).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a dead distro instead of an empty git error', async () => {
|
||||
execFileMock.mockImplementation((_command, _args, _options, callback) => {
|
||||
const child = new EventEmitter() as EventEmitter & { pid: number; kill: () => void }
|
||||
child.pid = 4321
|
||||
child.kill = () => {}
|
||||
queueMicrotask(() =>
|
||||
callback?.(
|
||||
hostFailure(asUtf16Mojibake(WSL_DIAGNOSTIC)),
|
||||
asUtf16Mojibake(WSL_DIAGNOSTIC),
|
||||
''
|
||||
)
|
||||
)
|
||||
return child
|
||||
})
|
||||
|
||||
const failure = await withPlatform('win32', () =>
|
||||
gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
|
||||
cwd: '\\\\wsl.localhost\\kali-linux\\home\\paulius\\bugbounty'
|
||||
}).then(
|
||||
() => null,
|
||||
(error: unknown) => error as Error
|
||||
)
|
||||
)
|
||||
|
||||
expect(failure?.message).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
|
||||
expect(execFileMock.mock.calls.at(-1)?.[2]?.env?.WSL_UTF8).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ResolvedCommand } from './wsl-command-resolution'
|
||||
|
||||
/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */
|
||||
export const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff
|
||||
|
||||
function outputText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return Buffer.isBuffer(value) ? value.toString('utf8') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The message wsl.exe prints when it — not the guest — failed: a distro that was renamed or
|
||||
* removed, or a VM that would not start.
|
||||
*
|
||||
* Why this needs decoding at all: wsl.exe exits 0xFFFFFFFF, leaves stderr EMPTY, and writes
|
||||
* `Error code: Wsl/Service/WSL_E_*` to stdout, so every caller that reports stderr reports nothing.
|
||||
* NULs are stripped because a wsl.exe that ignores WSL_UTF8 writes that line as UTF-16LE (#9010).
|
||||
*/
|
||||
export function readWslHostFailureDiagnostic(
|
||||
error: unknown,
|
||||
command: ResolvedCommand
|
||||
): string | null {
|
||||
if (!command.wsl || !error || typeof error !== 'object') {
|
||||
return null
|
||||
}
|
||||
const { code, status, stdout, stderr } = error as {
|
||||
code?: unknown
|
||||
status?: unknown
|
||||
stdout?: unknown
|
||||
stderr?: unknown
|
||||
}
|
||||
const exitCode = typeof code === 'number' ? code : typeof status === 'number' ? status : null
|
||||
// A guest failure always explains itself on stderr; an empty one plus this exit is the host.
|
||||
if (exitCode !== WSL_HOST_FAILURE_EXIT_CODE || outputText(stderr).trim().length > 0) {
|
||||
return null
|
||||
}
|
||||
const diagnostic = outputText(stdout).replaceAll('\u0000', '').trim()
|
||||
return diagnostic.length > 0 ? diagnostic : 'wsl.exe reported no diagnostic.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a wsl.exe host failure into the error's message, which is what `git.exec` spans record ahead
|
||||
* of the stack. Left untouched when the failure came from git itself.
|
||||
*/
|
||||
export function annotateWslHostFailure(error: unknown, command: ResolvedCommand): unknown {
|
||||
const diagnostic = readWslHostFailureDiagnostic(error, command)
|
||||
if (diagnostic === null || !(error instanceof Error) || !command.wsl) {
|
||||
return error
|
||||
}
|
||||
const distro = command.wsl.distro
|
||||
error.message = `wsl.exe host failure (distro "${distro}"): ${diagnostic}\n${error.message}`
|
||||
return Object.assign(error, { wslHostFailure: true, wslDistro: distro })
|
||||
}
|
||||
@@ -26,17 +26,22 @@ export async function getSubmoduleStatus(
|
||||
const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)
|
||||
const limit = resolveGitStatusLimit(options.limit)
|
||||
// Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work.
|
||||
const workingResult = options.staged
|
||||
? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult)
|
||||
: await getStatus(submoduleWorktreePath, options)
|
||||
// Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows.
|
||||
const fromOid = options.staged
|
||||
? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
|
||||
: (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) ||
|
||||
(await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options))
|
||||
const toOid = options.staged
|
||||
? await readGitlinkOidFromIndex(worktreePath, submodulePath, options)
|
||||
: await readWorkingSubmoduleHead(submoduleWorktreePath, options)
|
||||
// These three reads are independent, so they run concurrently — on SSH/WSL each one is a real round trip.
|
||||
const [workingResult, fromOid, toOid] = await Promise.all([
|
||||
options.staged
|
||||
? Promise.resolve<GitStatusResult>({ entries: [], conflictOperation: 'unknown' })
|
||||
: getStatus(submoduleWorktreePath, options),
|
||||
// Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows.
|
||||
options.staged
|
||||
? readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
|
||||
: readGitlinkOidFromIndex(worktreePath, submodulePath, options).then(
|
||||
(indexOid) =>
|
||||
indexOid || readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
|
||||
),
|
||||
options.staged
|
||||
? readGitlinkOidFromIndex(worktreePath, submodulePath, options)
|
||||
: readWorkingSubmoduleHead(submoduleWorktreePath, options)
|
||||
])
|
||||
if (fromOid && toOid && fromOid !== toOid) {
|
||||
const rangeEntries = await computeSubmoduleRangeEntries(
|
||||
submoduleWorktreePath,
|
||||
|
||||
@@ -395,4 +395,35 @@ describe('getSubmoduleStatus', () => {
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
expect(result.statusLength).toBe(2)
|
||||
})
|
||||
|
||||
it('overlaps the inner status, index and HEAD reads instead of serializing them', async () => {
|
||||
const OLD_OID = 'a'.repeat(40)
|
||||
const NEW_OID = 'b'.repeat(40)
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const events: string[] = []
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
const name = args.includes('status') ? 'status' : args[0]
|
||||
events.push(`start:${name}`)
|
||||
if (name === 'status') {
|
||||
// Hold the inner status open so a serialized caller could not have started the oid reads.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
events.push(`end:${name}`)
|
||||
if (name === 'ls-files') {
|
||||
return { stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }
|
||||
}
|
||||
if (name === 'rev-parse') {
|
||||
return { stdout: `${NEW_OID}\n` }
|
||||
}
|
||||
return { stdout: '' }
|
||||
})
|
||||
|
||||
await getSubmoduleStatus('/repo', 'flutter_mine')
|
||||
|
||||
// Serialized reads only start ls-files/rev-parse once the inner status has resolved.
|
||||
expect(events.indexOf('start:ls-files')).toBeLessThan(events.indexOf('end:status'))
|
||||
expect(events.indexOf('start:rev-parse')).toBeLessThan(events.indexOf('end:status'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,17 +35,7 @@ export async function listWorktreeGraph(
|
||||
? worktrees
|
||||
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
|
||||
} catch (err) {
|
||||
if (getErrorCode(err) === 'ENOENT') {
|
||||
try {
|
||||
await stat(repoPath)
|
||||
} catch (statErr) {
|
||||
if (getErrorCode(statErr) === 'ENOENT') {
|
||||
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNotGitRepositoryError(err)) {
|
||||
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
||||
return []
|
||||
}
|
||||
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err)
|
||||
@@ -64,17 +54,7 @@ export async function listWorktreesUnshared(
|
||||
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
|
||||
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
|
||||
} catch (err) {
|
||||
if (getErrorCode(err) === 'ENOENT') {
|
||||
try {
|
||||
await stat(repoPath)
|
||||
} catch (statErr) {
|
||||
if (getErrorCode(statErr) === 'ENOENT') {
|
||||
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isNotGitRepositoryError(err)) {
|
||||
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
||||
return []
|
||||
}
|
||||
// Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors.
|
||||
@@ -83,6 +63,24 @@ export async function listWorktreesUnshared(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two failures where an empty listing is the repo's true answer, not a broken scan: the repo
|
||||
* path is gone, or it is not a Git repo. Every other failure means the scan could not read Git.
|
||||
*/
|
||||
async function isTrueEmptyWorktreeListing(repoPath: string, err: unknown): Promise<boolean> {
|
||||
if (getErrorCode(err) === 'ENOENT') {
|
||||
try {
|
||||
await stat(repoPath)
|
||||
} catch (statErr) {
|
||||
if (getErrorCode(statErr) === 'ENOENT') {
|
||||
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return isNotGitRepositoryError(err)
|
||||
}
|
||||
|
||||
export async function listWorktreesStrict(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
@@ -97,6 +95,28 @@ export async function listWorktreesStrict(
|
||||
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict except for the two true empties above.
|
||||
*
|
||||
* Why: a Git or host failure (dead WSL distro, hung mount) softened to `[]` reaches the detected
|
||||
* listing as an *authoritative* empty scan, which then permanently prunes the repo's worktrees and
|
||||
* the agent tabs attached to them. Rejecting keeps that listing non-authoritative, while a deleted
|
||||
* repo still reports empty so real removals prune.
|
||||
*/
|
||||
export async function listWorktreesStrictAllowingTrueEmpty(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
try {
|
||||
return await listWorktreesStrict(repoPath, options)
|
||||
} catch (err) {
|
||||
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function annotateSparseCheckoutStatus(
|
||||
repoPath: string,
|
||||
worktrees: GitWorktreeInfo[],
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import {
|
||||
annotateSparseCheckoutStatus,
|
||||
listWorktreeGraph as listWorktreeGraphUnshared,
|
||||
listWorktreesStrict as listWorktreesStrictUnshared
|
||||
listWorktreesStrict as listWorktreesStrictUnshared,
|
||||
listWorktreesStrictAllowingTrueEmpty as listWorktreesStrictAllowingTrueEmptyUnshared
|
||||
} from './worktree-listing'
|
||||
import type { GitWorktreeExecOptions } from './worktree-operation-options'
|
||||
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
|
||||
@@ -10,7 +11,7 @@ import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
|
||||
// Why: share concurrent `git worktree list` scans, which are expensive on Windows.
|
||||
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
|
||||
|
||||
type WorktreeScanKind = 'graph' | 'lenient' | 'strict'
|
||||
type WorktreeScanKind = 'graph' | 'lenient' | 'strict' | 'strict-true-empty'
|
||||
|
||||
// Why: mutation generations prevent listings from joining stale scans.
|
||||
const worktreeScanGenerations = new Map<string, number>()
|
||||
@@ -139,3 +140,20 @@ export function listWorktreesSharedStrict(
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
return shareWorktreeScan(repoPath, options, 'strict', listWorktreesStrictUnshared)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detected scan's discipline: reject a Git/host failure so it cannot publish as an
|
||||
* authoritative empty listing, but still answer `[]` for a repo that is gone or not a repo.
|
||||
* Its own kind because neither a strict nor a lenient joiner may inherit that middle contract.
|
||||
*/
|
||||
export function listWorktreesSharedStrictAllowingTrueEmpty(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
return shareWorktreeScan(
|
||||
repoPath,
|
||||
options,
|
||||
'strict-true-empty',
|
||||
listWorktreesStrictAllowingTrueEmptyUnshared
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export {
|
||||
_resetWorktreeScanCacheForTests,
|
||||
listWorktreeGraph,
|
||||
listWorktrees,
|
||||
listWorktreesSharedStrict
|
||||
listWorktreesSharedStrict,
|
||||
listWorktreesSharedStrictAllowingTrueEmpty
|
||||
} from './worktree-scan-cache'
|
||||
export { bumpWorktreeScanGeneration as notifyPreparedWorktreeMutation } from './worktree-scan-cache'
|
||||
export { addSparseWorktree } from './worktree-sparse-add'
|
||||
|
||||
@@ -49,6 +49,7 @@ function recordRendererBreadcrumbTrace(
|
||||
const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees'
|
||||
const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn'
|
||||
const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade'
|
||||
const REPLAY_GUARD_WEDGED_BREADCRUMB = 'terminal_replay_guard_wedged_release'
|
||||
const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
|
||||
'renderer_error',
|
||||
'renderer_unhandled_rejection',
|
||||
@@ -56,6 +57,7 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
|
||||
DUPLICATE_TAB_OWNER_BREADCRUMB,
|
||||
PARK_VERDICT_CHURN_BREADCRUMB,
|
||||
REACT_COMMIT_CASCADE_BREADCRUMB,
|
||||
REPLAY_GUARD_WEDGED_BREADCRUMB,
|
||||
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
|
||||
])
|
||||
const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
|
||||
@@ -69,6 +71,11 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
|
||||
// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count
|
||||
// — the only signal these carry — in one slot.
|
||||
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted'])
|
||||
// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For
|
||||
// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the
|
||||
// only place a burst survives the restart that clears the ring, so coalesce the ring
|
||||
// but keep every event's span.
|
||||
const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB])
|
||||
|
||||
function rendererBreadcrumbCoalesceKey(
|
||||
name: string,
|
||||
@@ -77,6 +84,13 @@ function rendererBreadcrumbCoalesceKey(
|
||||
if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) {
|
||||
return name
|
||||
}
|
||||
// Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call
|
||||
// site (layout-serialization restoreScrollbackBuffers) and present on reattach, so
|
||||
// their presence is the call-site identity a mixed burst would otherwise lose. Four
|
||||
// slots per storm at most, regardless of pane count.
|
||||
if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) {
|
||||
return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}`
|
||||
}
|
||||
// Why trigger and not name alone: `burst` means damping engaged a commit
|
||||
// short of React #185, `window` means slow benign churn. Collapsing them
|
||||
// would drop the near-crash signal into a slow-churn slot. Still bounded —
|
||||
@@ -191,9 +205,13 @@ export function recordRendererBreadcrumbFromRenderer(
|
||||
minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS,
|
||||
...(origin ? { origin } : {})
|
||||
})
|
||||
// Why: tracing every suppressed duplicate would preserve the same
|
||||
// serialization and disk churn that breadcrumb coalescing removes.
|
||||
if (coalesceResult) {
|
||||
if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) {
|
||||
// Why the raw data: every event already gets its own span, so folding the ring's
|
||||
// running count in here would double-count in any span-stream total.
|
||||
recordRendererBreadcrumbTrace(args.name, data)
|
||||
} else if (coalesceResult) {
|
||||
// Why gated: tracing every suppressed duplicate would preserve the same
|
||||
// serialization and disk churn that breadcrumb coalescing removes.
|
||||
recordRendererBreadcrumbTrace(
|
||||
args.name,
|
||||
coalesceResult.suppressedSinceLast > 0
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
clearCrashBreadcrumbsForTest,
|
||||
getCrashBreadcrumbSnapshot,
|
||||
recordCrashBreadcrumb
|
||||
} from '../crash-reporting/crash-breadcrumb-store'
|
||||
import { recordRendererBreadcrumbFromRenderer } from './crash-reporting-renderer-breadcrumbs'
|
||||
|
||||
type SpanOptions = { attributes: Record<string, unknown> }
|
||||
const startSpanMock = vi.fn((_name: string, _options: SpanOptions) => ({ end: () => {} }))
|
||||
vi.mock('../observability/tracer', () => ({
|
||||
startSpan: (name: string, options: SpanOptions) => startSpanMock(name, options)
|
||||
}))
|
||||
|
||||
const WEDGE_BREADCRUMB = 'terminal_replay_guard_wedged_release'
|
||||
|
||||
/** Reattach-path shape: identity-bearing (`tabIdHash`, optionally `ptyId`). */
|
||||
function emitReattachWedge(pane: number, withPtyId = false): void {
|
||||
recordRendererBreadcrumbFromRenderer({
|
||||
name: WEDGE_BREADCRUMB,
|
||||
data: {
|
||||
paneId: pane,
|
||||
leafIdHash: `leaf${String(pane).padStart(5, '0')}`,
|
||||
tabIdHash: `tab${String(pane).padStart(6, '0')}`,
|
||||
worktreeIdHash: 'caa15fa9',
|
||||
...(withPtyId ? { ptyId: `…@@pty-${pane}` } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Restore-path shape (restoreScrollbackBuffers): no tabIdHash, no ptyId. */
|
||||
function emitRestoreWedge(pane: number): void {
|
||||
recordRendererBreadcrumbFromRenderer({
|
||||
name: WEDGE_BREADCRUMB,
|
||||
data: { paneId: pane, leafIdHash: `leaf${String(pane).padStart(5, '0')}` }
|
||||
})
|
||||
}
|
||||
|
||||
function wedgeCrumbs(): ReturnType<typeof getCrashBreadcrumbSnapshot> {
|
||||
return getCrashBreadcrumbSnapshot().filter((entry) => entry.name === WEDGE_BREADCRUMB)
|
||||
}
|
||||
|
||||
function wedgeSpanCount(): number {
|
||||
return startSpanMock.mock.calls.filter(
|
||||
(call) => call[1].attributes['breadcrumb.name'] === WEDGE_BREADCRUMB
|
||||
).length
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
startSpanMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearCrashBreadcrumbsForTest()
|
||||
})
|
||||
|
||||
// One mount/reveal/wake transition expires every in-flight replay write at once, so
|
||||
// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure
|
||||
// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows
|
||||
// a ring that actually drained — all nine bursts predate their report's ring window —
|
||||
// so this bounds a demonstrated hazard, not an observed loss, and must not cost the
|
||||
// durable span evidence that did carry those bursts.
|
||||
describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => {
|
||||
it('costs one ring slot per call site and preserves the pre-crash trail', () => {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
recordCrashBreadcrumb(`pre_crash_evidence_${index}`, { index })
|
||||
}
|
||||
|
||||
for (let pane = 0; pane < 26; pane += 1) {
|
||||
emitReattachWedge(pane)
|
||||
}
|
||||
|
||||
const snapshot = getCrashBreadcrumbSnapshot()
|
||||
expect(snapshot.filter((entry) => entry.name.startsWith('pre_crash_evidence_'))).toHaveLength(
|
||||
10
|
||||
)
|
||||
expect(wedgeCrumbs()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('carries the burst multiplicity into the ring as suppressedSinceLast', () => {
|
||||
for (let pane = 0; pane < 26; pane += 1) {
|
||||
emitReattachWedge(pane)
|
||||
}
|
||||
|
||||
// 26 emissions: one owns the slot, 25 fold into it.
|
||||
expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25)
|
||||
})
|
||||
|
||||
// The 121-event field corpus lives entirely in the renderer.breadcrumb span stream,
|
||||
// and the ring is cleared by the restart that usually precedes the crash report, so
|
||||
// ring coalescing must not suppress the per-event spans.
|
||||
it('still emits one durable span per wedge event', () => {
|
||||
for (let pane = 0; pane < 26; pane += 1) {
|
||||
emitReattachWedge(pane)
|
||||
}
|
||||
|
||||
expect(wedgeSpanCount()).toBe(26)
|
||||
// Why no count on the span: one span per event already carries the multiplicity.
|
||||
expect(
|
||||
startSpanMock.mock.calls.some((call) =>
|
||||
JSON.stringify(call[1]).includes('suppressedSinceLast')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
// Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one
|
||||
// window; name-only keying would report only the last one's shape.
|
||||
it('keeps restore-path and reattach-path call sites in separate slots', () => {
|
||||
emitRestoreWedge(1)
|
||||
emitRestoreWedge(2)
|
||||
emitReattachWedge(3)
|
||||
emitReattachWedge(4, true)
|
||||
|
||||
const crumbs = wedgeCrumbs()
|
||||
expect(crumbs).toHaveLength(3)
|
||||
expect(crumbs.map((crumb) => Boolean(crumb.data?.tabIdHash))).toEqual([false, true, true])
|
||||
expect(crumbs.map((crumb) => Boolean(crumb.data?.ptyId))).toEqual([false, false, true])
|
||||
})
|
||||
|
||||
it('bounds a many-pane burst to one slot within a call site', () => {
|
||||
for (let pane = 0; pane < 40; pane += 1) {
|
||||
emitReattachWedge(pane, pane % 2 === 0)
|
||||
}
|
||||
|
||||
expect(wedgeCrumbs()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as RepoWorktrees from '../repo-worktrees'
|
||||
import { listRepoWorktreeGraph } from '../repo-worktrees'
|
||||
import type * as ProjectGroupsModule from '../../shared/project-groups'
|
||||
import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
import type { Project } from '../../shared/project-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getAllowedRoots } from './filesystem-allowed-roots'
|
||||
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
|
||||
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
||||
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
|
||||
|
||||
vi.mock('../repo-worktrees', async () => {
|
||||
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
|
||||
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
|
||||
})
|
||||
|
||||
vi.mock('../../shared/project-groups', async () => {
|
||||
const actual = await vi.importActual<typeof ProjectGroupsModule>('../../shared/project-groups')
|
||||
return {
|
||||
...actual,
|
||||
buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex),
|
||||
getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds)
|
||||
}
|
||||
})
|
||||
|
||||
type StoreFixture = {
|
||||
repos: Repo[]
|
||||
projects: Project[]
|
||||
projectGroups: ProjectGroup[]
|
||||
folderWorkspaces: FolderWorkspace[]
|
||||
workspaceDir?: string
|
||||
}
|
||||
|
||||
type StoreCallCounts = {
|
||||
getRepos: number
|
||||
getProjects: number
|
||||
getProjectGroups: number
|
||||
getFolderWorkspaces: number
|
||||
}
|
||||
|
||||
function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } {
|
||||
const counts: StoreCallCounts = {
|
||||
getRepos: 0,
|
||||
getProjects: 0,
|
||||
getProjectGroups: 0,
|
||||
getFolderWorkspaces: 0
|
||||
}
|
||||
const store = {
|
||||
getRepos: () => {
|
||||
counts.getRepos += 1
|
||||
// Match the real store, which rehydrates fresh repo objects on every read.
|
||||
return fixture.repos.map((repo) => ({ ...repo }))
|
||||
},
|
||||
getProjects: () => {
|
||||
counts.getProjects += 1
|
||||
return fixture.projects.map((project) => ({ ...project }))
|
||||
},
|
||||
getProjectGroups: () => {
|
||||
counts.getProjectGroups += 1
|
||||
return fixture.projectGroups.map((group) => ({ ...group }))
|
||||
},
|
||||
getFolderWorkspaces: () => {
|
||||
counts.getFolderWorkspaces += 1
|
||||
return fixture.folderWorkspaces.map((workspace) => ({ ...workspace }))
|
||||
},
|
||||
getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' })
|
||||
} as unknown as Store
|
||||
return { store, counts }
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the
|
||||
* new root list against the old one rather than against a hand-written expectation.
|
||||
*/
|
||||
function referenceAllowedRoots(store: Store): string[] {
|
||||
const scopeStore = store as unknown as {
|
||||
getRepos: () => Repo[]
|
||||
getProjectGroups?: () => ProjectGroup[]
|
||||
getFolderWorkspaces?: () => FolderWorkspace[]
|
||||
getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean }
|
||||
}
|
||||
const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId)
|
||||
const settings = scopeStore.getSettings()
|
||||
|
||||
const scopeRepos = scopeStore.getRepos()
|
||||
const projectGroups = scopeStore.getProjectGroups?.() ?? []
|
||||
const isRemoteOnly = (
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
connectionId: string | null | undefined
|
||||
): boolean => {
|
||||
if (connectionId) {
|
||||
return true
|
||||
}
|
||||
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
|
||||
const candidates = scopeRepos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
)
|
||||
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
|
||||
}
|
||||
const folderScopeRoots: string[] = []
|
||||
for (const group of projectGroups) {
|
||||
if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) {
|
||||
folderScopeRoots.push(resolve(group.parentPath))
|
||||
}
|
||||
}
|
||||
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
|
||||
const connectionId =
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
|
||||
null
|
||||
if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) {
|
||||
folderScopeRoots.push(resolve(workspace.folderPath))
|
||||
}
|
||||
}
|
||||
|
||||
const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots]
|
||||
if (settings.workspaceDir) {
|
||||
if (localRepos.length === 0) {
|
||||
roots.push(resolve(settings.workspaceDir))
|
||||
} else {
|
||||
for (const repo of localRepos) {
|
||||
roots.push(
|
||||
resolve(
|
||||
computeWorkspaceRoot(
|
||||
repo.path,
|
||||
getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
|
||||
return {
|
||||
displayName: overrides.id,
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeGroup(overrides: Partial<ProjectGroup> & Pick<ProjectGroup, 'id'>): ProjectGroup {
|
||||
return {
|
||||
name: overrides.id,
|
||||
parentPath: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorkspace(
|
||||
overrides: Partial<FolderWorkspace> & Pick<FolderWorkspace, 'id' | 'folderPath'>
|
||||
): FolderWorkspace {
|
||||
return {
|
||||
projectGroupId: 'group-root',
|
||||
name: overrides.id,
|
||||
comment: '',
|
||||
linkedTask: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */
|
||||
function makeMixedFixture(): StoreFixture {
|
||||
const repos = [
|
||||
makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }),
|
||||
makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }),
|
||||
makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }),
|
||||
makeRepo({
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/app',
|
||||
connectionId: 'ssh-1',
|
||||
projectGroupId: 'group-remote'
|
||||
})
|
||||
]
|
||||
const projectGroups = [
|
||||
makeGroup({ id: 'group-root', parentPath: '/folders/root' }),
|
||||
makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }),
|
||||
makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }),
|
||||
makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }),
|
||||
makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' })
|
||||
]
|
||||
const folderWorkspaces = [
|
||||
makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }),
|
||||
// Not a git worktree: a plain folder workspace under a folder-kind repo.
|
||||
makeWorkspace({
|
||||
id: 'ws-plain',
|
||||
folderPath: '/folders/plain/scratch',
|
||||
projectGroupId: 'group-child'
|
||||
}),
|
||||
makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }),
|
||||
makeWorkspace({
|
||||
id: 'ws-connection',
|
||||
folderPath: '/remote/direct',
|
||||
projectGroupId: 'group-connection'
|
||||
}),
|
||||
makeWorkspace({
|
||||
id: 'ws-unlinked',
|
||||
folderPath: '/folders/unlinked',
|
||||
projectGroupId: 'group-orphan'
|
||||
})
|
||||
]
|
||||
const projects: Project[] = [
|
||||
{
|
||||
id: 'project-1',
|
||||
displayName: 'App',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-local', 'repo-nested'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'project-2',
|
||||
displayName: 'Folder',
|
||||
badgeColor: '#000000',
|
||||
sourceRepoIds: ['repo-folder'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
vi.mocked(buildProjectGroupChildIndex).mockClear()
|
||||
vi.mocked(getProjectGroupSubtreeIds).mockClear()
|
||||
})
|
||||
|
||||
describe('getAllowedRoots', () => {
|
||||
it('produces the same roots as the pre-change implementation', () => {
|
||||
const { store } = makeCountingStore(makeMixedFixture())
|
||||
|
||||
expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store))
|
||||
})
|
||||
|
||||
it('reads the store once and indexes project groups once per build', () => {
|
||||
const fixture = makeMixedFixture()
|
||||
const { store, counts } = makeCountingStore(fixture)
|
||||
|
||||
getAllowedRoots(store)
|
||||
|
||||
expect.soft(counts.getRepos).toBe(1)
|
||||
expect.soft(counts.getProjectGroups).toBe(1)
|
||||
expect.soft(counts.getFolderWorkspaces).toBe(1)
|
||||
// Batched runtime resolution scans the project list once, not once per local repo.
|
||||
expect.soft(counts.getProjects).toBe(1)
|
||||
// The per-scope subtree walk no longer rebuilds the parent->children index.
|
||||
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1)
|
||||
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAuthorizedPath allowed-root reuse', () => {
|
||||
let repoRoot: string
|
||||
let outsideRoot: string
|
||||
let store: Store
|
||||
let counts: StoreCallCounts
|
||||
|
||||
beforeEach(async () => {
|
||||
repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-'))
|
||||
outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-'))
|
||||
const fixture = makeMixedFixture()
|
||||
fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos]
|
||||
fixture.projects[0]!.sourceRepoIds = ['repo-local']
|
||||
;({ store, counts } = makeCountingStore(fixture))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true })
|
||||
await rm(outsideRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('builds the allowed-root list once per call across repeated reads', async () => {
|
||||
const dirPath = join(repoRoot, 'src')
|
||||
await mkdir(dirPath)
|
||||
await writeFile(join(dirPath, 'index.ts'), 'export {}\n')
|
||||
const callCount = 5
|
||||
|
||||
for (let index = 0; index < callCount; index += 1) {
|
||||
await resolveAuthorizedPath(dirPath, store)
|
||||
await resolveAuthorizedPath(join(dirPath, 'index.ts'), store)
|
||||
}
|
||||
|
||||
const buildCount = callCount * 2
|
||||
// One build per authorization, not one per raw-path check plus one per realpath check.
|
||||
expect.soft(counts.getFolderWorkspaces).toBe(buildCount)
|
||||
expect.soft(counts.getRepos).toBe(buildCount)
|
||||
expect.soft(counts.getProjects).toBe(buildCount)
|
||||
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount)
|
||||
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why (both symlink cases): creating a symlink on Windows needs elevation or
|
||||
// Developer Mode, so these would fail EPERM in setup rather than exercise the
|
||||
// escape check. Every non-symlink case still runs there.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'still refuses a symlink that escapes every allowed root',
|
||||
async () => {
|
||||
const secret = join(outsideRoot, 'secret.txt')
|
||||
await writeFile(secret, 'secret\n')
|
||||
const escape = join(repoRoot, 'escape.txt')
|
||||
await symlink(secret, escape)
|
||||
|
||||
await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied')
|
||||
expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('builds no allowed-root list at all for a granted external path', async () => {
|
||||
const external = join(outsideRoot, 'external.md')
|
||||
await writeFile(external, 'notes\n')
|
||||
authorizeExternalPath(external)
|
||||
counts.getRepos = 0
|
||||
counts.getProjects = 0
|
||||
counts.getFolderWorkspaces = 0
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
|
||||
}
|
||||
|
||||
// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
|
||||
expect.soft(counts.getRepos).toBe(0)
|
||||
expect.soft(counts.getProjects).toBe(0)
|
||||
expect.soft(counts.getFolderWorkspaces).toBe(0)
|
||||
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'still refuses a directory symlink that escapes every allowed root',
|
||||
async () => {
|
||||
const outsideDir = join(outsideRoot, 'nested')
|
||||
await mkdir(outsideDir)
|
||||
await writeFile(join(outsideDir, 'file.txt'), 'secret\n')
|
||||
const escape = join(repoRoot, 'escape-dir')
|
||||
await symlink(outsideDir, escape)
|
||||
|
||||
await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow(
|
||||
'Access denied'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,9 +1,16 @@
|
||||
import { resolve } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
|
||||
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
|
||||
import {
|
||||
getWorktreeMirrorDistroForRuntime,
|
||||
resolveLocalProjectRuntimesForRepos
|
||||
} from '../project-runtime-git-options'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import {
|
||||
buildProjectGroupChildIndex,
|
||||
collectProjectGroupSubtreeIds,
|
||||
type ProjectGroupChildIndex
|
||||
} from '../../shared/project-groups'
|
||||
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
|
||||
import type { ProjectGroup } from '../../shared/project-group-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types'
|
||||
type FolderScopeStore = Pick<Store, 'getRepos'> &
|
||||
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
|
||||
|
||||
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
|
||||
function filterLocalRepos(repos: readonly Repo[]): Repo[] {
|
||||
return repos.filter((repo) => !repo.connectionId)
|
||||
}
|
||||
|
||||
export function getLocalRepos(store: Store) {
|
||||
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
|
||||
return store.getRepos().filter((repo) => !repo.connectionId)
|
||||
return filterLocalRepos(store.getRepos())
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
childGroupIndex: ProjectGroupChildIndex,
|
||||
repos: readonly Repo[]
|
||||
): Repo[] {
|
||||
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
|
||||
const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId)
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
@@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
connectionId: string | null | undefined,
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
childGroupIndex: ProjectGroupChildIndex,
|
||||
repos: readonly Repo[]
|
||||
): boolean {
|
||||
if (connectionId) {
|
||||
return true
|
||||
}
|
||||
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
|
||||
const candidates = getFolderScopeCandidateRepos(
|
||||
folderPath,
|
||||
projectGroupId,
|
||||
childGroupIndex,
|
||||
repos
|
||||
)
|
||||
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
|
||||
}
|
||||
|
||||
@@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId(
|
||||
)
|
||||
}
|
||||
|
||||
function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] {
|
||||
const scopeStore = store as FolderScopeStore
|
||||
const repos = scopeStore.getRepos()
|
||||
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
|
||||
const projectGroups = scopeStore.getProjectGroups?.() ?? []
|
||||
const childGroupIndex = buildProjectGroupChildIndex(projectGroups)
|
||||
const roots: string[] = []
|
||||
for (const group of projectGroups) {
|
||||
if (
|
||||
group.parentPath &&
|
||||
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
|
||||
!isRemoteOnlyFolderScope(
|
||||
group.parentPath,
|
||||
group.id,
|
||||
group.connectionId,
|
||||
childGroupIndex,
|
||||
repos
|
||||
)
|
||||
) {
|
||||
roots.push(resolve(group.parentPath))
|
||||
}
|
||||
@@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
workspace.folderPath,
|
||||
workspace.projectGroupId,
|
||||
getFolderWorkspaceConnectionId(workspace, projectGroups),
|
||||
projectGroups,
|
||||
childGroupIndex,
|
||||
repos
|
||||
)
|
||||
) {
|
||||
@@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
}
|
||||
|
||||
export function getAllowedRoots(store: Store): string[] {
|
||||
const localRepos = getLocalRepos(store)
|
||||
// Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC.
|
||||
const repos = store.getRepos()
|
||||
const localRepos = filterLocalRepos(repos)
|
||||
const settings = store.getSettings()
|
||||
const roots = [
|
||||
...localRepos.map((repo) => resolve(repo.path)),
|
||||
...getLocalFolderScopeRoots(store)
|
||||
...getLocalFolderScopeRoots(store, repos)
|
||||
]
|
||||
if (settings.workspaceDir) {
|
||||
if (localRepos.length === 0) {
|
||||
roots.push(resolve(settings.workspaceDir))
|
||||
} else {
|
||||
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos)
|
||||
for (const repo of localRepos) {
|
||||
roots.push(
|
||||
resolve(
|
||||
@@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] {
|
||||
// Why enriched here too: placement has to agree with the create
|
||||
// flow, or renderer file access is denied for a worktree Orca
|
||||
// just put on the WSL side.
|
||||
getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo))
|
||||
getWorktreePathSettings(
|
||||
repo,
|
||||
settings,
|
||||
getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isPathAllowed(targetPath: string, store: Store): boolean {
|
||||
/**
|
||||
* One allowed-root list shared by every check in a single authorization.
|
||||
*
|
||||
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
|
||||
* before the list was hoisted out of the individual checks.
|
||||
*/
|
||||
type AllowedRootsSnapshot = { get: () => readonly string[] }
|
||||
|
||||
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
|
||||
let roots: readonly string[] | undefined
|
||||
return { get: () => (roots ??= getAllowedRoots(store)) }
|
||||
}
|
||||
|
||||
export function isPathAllowed(
|
||||
targetPath: string,
|
||||
store: Store,
|
||||
allowedRoots?: AllowedRootsSnapshot
|
||||
): boolean {
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
if (authorizedExternalPaths.has(resolvedTarget)) {
|
||||
return true
|
||||
@@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root))
|
||||
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
|
||||
isDescendantOrEqual(resolvedTarget, root)
|
||||
)
|
||||
}
|
||||
|
||||
export type ResolveAuthorizedPathOptions = {
|
||||
@@ -69,7 +88,10 @@ export async function resolveAuthorizedPath(
|
||||
options: ResolveAuthorizedPathOptions = {}
|
||||
): Promise<string> {
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) {
|
||||
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
|
||||
// every authorization below; each candidate is still checked against it in full.
|
||||
const allowedRoots = createAllowedRootsSnapshot(store)
|
||||
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
}
|
||||
|
||||
@@ -80,14 +102,15 @@ export async function resolveAuthorizedPath(
|
||||
realParent = await realpath(dirname(resolvedTarget))
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) {
|
||||
return resolveAuthorizedMissingPath(resolvedTarget, store)
|
||||
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const candidateTarget = resolve(realParent, basename(resolvedTarget))
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
canonicalSourcePath: resolvedTarget,
|
||||
allowedRoots
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
@@ -100,7 +123,8 @@ export async function resolveAuthorizedPath(
|
||||
const realTarget = resolve(await realpath(resolvedTarget))
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
canonicalSourcePath: resolvedTarget,
|
||||
allowedRoots
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
@@ -110,11 +134,15 @@ export async function resolveAuthorizedPath(
|
||||
if (!isENOENT(error)) {
|
||||
throw error
|
||||
}
|
||||
return resolveAuthorizedMissingPath(resolvedTarget, store)
|
||||
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise<string> {
|
||||
async function resolveAuthorizedMissingPath(
|
||||
resolvedTarget: string,
|
||||
store: Store,
|
||||
allowedRoots: AllowedRootsSnapshot
|
||||
): Promise<string> {
|
||||
let existingAncestor = resolvedTarget
|
||||
const missingSegments: string[] = []
|
||||
|
||||
@@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
|
||||
const candidateTarget = resolve(realAncestor, ...missingSegments)
|
||||
if (
|
||||
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
||||
canonicalSourcePath: resolvedTarget
|
||||
canonicalSourcePath: resolvedTarget,
|
||||
allowedRoots
|
||||
}))
|
||||
) {
|
||||
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
||||
@@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
|
||||
async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
targetPath: string,
|
||||
store: Store,
|
||||
options: { canonicalSourcePath?: string } = {}
|
||||
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
|
||||
): Promise<boolean> {
|
||||
if (isPathAllowed(targetPath, store)) {
|
||||
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
return true
|
||||
}
|
||||
|
||||
if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) {
|
||||
if (
|
||||
await isPathAllowedByCanonicalAllowedRoot(
|
||||
targetPath,
|
||||
options.canonicalSourcePath,
|
||||
store,
|
||||
options.allowedRoots
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
async function isPathAllowedByCanonicalAllowedRoot(
|
||||
targetPath: string,
|
||||
sourcePath: string | undefined,
|
||||
store: Store
|
||||
store: Store,
|
||||
allowedRoots?: AllowedRootsSnapshot
|
||||
): Promise<boolean> {
|
||||
if (!sourcePath) {
|
||||
return false
|
||||
}
|
||||
for (const root of getAllowedRoots(store)) {
|
||||
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
|
||||
const resolvedRoot = resolve(root)
|
||||
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
|
||||
|
||||
export function broadcastOrcaProfileAuthStatusChanged(): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
|
||||
} catch {
|
||||
// A renderer can disappear between isDestroyed() and send().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
signOutCurrentOrcaProfile
|
||||
} from '../orca-profiles/profile-cloud-service'
|
||||
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
|
||||
import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation'
|
||||
import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast'
|
||||
|
||||
type RegisterOrcaProfileHandlersOptions = {
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
@@ -178,6 +180,12 @@ export function registerOrcaProfileHandlers(
|
||||
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
|
||||
)
|
||||
|
||||
// Why: a background refresh can revoke the session with no renderer request in
|
||||
// flight, so push the change instead of waiting for the next pane to ask.
|
||||
// Why not options.onAuthMutation: that hook drives the relay coordinator, which
|
||||
// is the caller that just failed the refresh — re-entering it here would be a loop.
|
||||
onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged)
|
||||
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:createLocal',
|
||||
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
|
||||
|
||||
@@ -170,7 +170,10 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:inspectProcess',
|
||||
async (_event, args: { id: string; expectedIncarnationId?: string }) => {
|
||||
async (
|
||||
_event,
|
||||
args: { id: string; expectedIncarnationId?: string; scanChildProcesses?: boolean }
|
||||
) => {
|
||||
// Why: same routing hazard as pty:hasPty — an unroutable id must read as client-only unverifiable, not as a local-provider answer or a raised IPC error.
|
||||
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
|
||||
return clientOnlyUnverifiableInspection('terminal_gone')
|
||||
@@ -182,10 +185,14 @@ export function installPtyInspectIpcHandlers(deps: {
|
||||
if (!hasPtyProviderForInspection(args.id)) {
|
||||
return clientOnlyUnverifiableInspection('terminal_gone')
|
||||
}
|
||||
return args.expectedIncarnationId
|
||||
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, {
|
||||
expectedIncarnationId: args.expectedIncarnationId
|
||||
})
|
||||
const options = {
|
||||
...(args.expectedIncarnationId
|
||||
? { expectedIncarnationId: args.expectedIncarnationId }
|
||||
: {}),
|
||||
...(args.scanChildProcesses === true ? { scanChildProcesses: true } : {})
|
||||
}
|
||||
return Object.keys(options).length > 0
|
||||
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, options)
|
||||
: inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input'
|
||||
import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import { createPtyWriteInput } from './write-input'
|
||||
|
||||
const PTY_ID = 'pty-chunk-yield'
|
||||
|
||||
const { provider } = vi.hoisted(() => ({ provider: { write: vi.fn() } }))
|
||||
|
||||
vi.mock('../provider/registry', () => ({
|
||||
tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined)
|
||||
}))
|
||||
|
||||
const realSetImmediate = globalThis.setImmediate
|
||||
const realReadmit = agentSessionPtyWriteGate.readmit.bind(agentSessionPtyWriteGate)
|
||||
const THREE_CHUNK_INPUT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)
|
||||
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: { isDestroyed: () => false, send: vi.fn() }
|
||||
}
|
||||
|
||||
/** Resolves after `turns` real check-phase passes; never touches the (faked) timer queue. */
|
||||
function afterImmediateTurns(turns: number): Promise<'stalled'> {
|
||||
return new Promise((resolve) => {
|
||||
const step = (remaining: number): void => {
|
||||
if (remaining === 0) {
|
||||
resolve('stalled')
|
||||
return
|
||||
}
|
||||
realSetImmediate(() => step(remaining - 1))
|
||||
}
|
||||
step(turns)
|
||||
})
|
||||
}
|
||||
|
||||
function createWriteInput(): ReturnType<typeof createPtyWriteInput>['writePtyInput'] {
|
||||
return createPtyWriteInput({
|
||||
mainWindow: mainWindow as never,
|
||||
clearHiddenRendererResizeOutput: vi.fn()
|
||||
}).writePtyInput
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ptyOwnership.set(PTY_ID, null)
|
||||
provider.write.mockReset()
|
||||
mainWindow.webContents.send.mockReset()
|
||||
// Why: only setTimeout is faked. A setTimeout(0) yield would stall the write forever here,
|
||||
// while a setImmediate yield still runs in Node's check phase — the race below is deterministic.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ptyOwnership.delete(PTY_ID)
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('chunked pty write yield', () => {
|
||||
it('yields between chunks via setImmediate, not a timer, and readmits before each later chunk', async () => {
|
||||
const events: string[] = []
|
||||
provider.write.mockImplementation((_id: string, data: string) => {
|
||||
events.push(`write:${data.length}`)
|
||||
})
|
||||
vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockImplementation((...args) => {
|
||||
events.push('readmit')
|
||||
return realReadmit(...args)
|
||||
})
|
||||
vi.spyOn(globalThis, 'setImmediate').mockImplementation(((callback: () => void) => {
|
||||
events.push('yield')
|
||||
return realSetImmediate(callback)
|
||||
}) as typeof setImmediate)
|
||||
|
||||
const outcome = await Promise.race([
|
||||
createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }),
|
||||
afterImmediateTurns(50)
|
||||
])
|
||||
|
||||
expect(outcome).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(events).toEqual([
|
||||
`write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`,
|
||||
'yield',
|
||||
'readmit',
|
||||
`write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`,
|
||||
'yield',
|
||||
'readmit',
|
||||
'write:8'
|
||||
])
|
||||
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not yield for input that fits in a single chunk', async () => {
|
||||
const immediate = vi.spyOn(globalThis, 'setImmediate')
|
||||
|
||||
const outcome = createWriteInput()({
|
||||
id: PTY_ID,
|
||||
data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
|
||||
})
|
||||
|
||||
expect(outcome).toBe(true)
|
||||
expect(provider.write).toHaveBeenCalledTimes(1)
|
||||
expect(immediate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops after a yield once readmission refuses', async () => {
|
||||
const readmit = vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockReturnValue({
|
||||
admitted: false,
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
sessionId: 'session-1',
|
||||
ownerRuntimeKind: null,
|
||||
handoffStage: null,
|
||||
ownerPid: null,
|
||||
runtimeFence: null
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = await Promise.race([
|
||||
createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }),
|
||||
afterImmediateTurns(50)
|
||||
])
|
||||
|
||||
expect(outcome).toBe(false)
|
||||
expect(provider.write).toHaveBeenCalledTimes(1)
|
||||
expect(readmit).toHaveBeenCalledTimes(1)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith(
|
||||
'pty:writeUnavailable',
|
||||
expect.objectContaining({ id: PTY_ID })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -152,7 +152,9 @@ export function createPtyWriteInput(deps: {
|
||||
first = false
|
||||
provider.write(id, chunk.value)
|
||||
if (!nextChunk.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
// setImmediate, not setTimeout(0): the yield exists to let abort/data callbacks run
|
||||
// between chunks, and a clamped timer tick per 16 KiB is pure latency.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
chunk = nextChunk
|
||||
nextChunk = chunks.next()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { sshProviders } from '../provider/registry'
|
||||
import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
|
||||
/**
|
||||
* `hostScopeCensusIsComplete` discounts a `runtime:` host in `omittedHostIds` on the strength of
|
||||
* one fact about this process: it has no paired-runtime PTY provider, so it never queried that
|
||||
* host and never owed it coverage. This file pins the producer side of that fact.
|
||||
*
|
||||
* What it catches: a new branch here that spells a queried host `runtime:`. Every id this
|
||||
* function emits is built by `toSshExecutionHostId` or is `LOCAL_EXECUTION_HOST_ID`, so a third
|
||||
* shape is the observable form of "a runtime host can now answer an inventory" — at which point
|
||||
* the client predicate would start calling a genuine gap complete.
|
||||
*
|
||||
* What it does NOT catch, so do not lean on it: a runtime-backed transport registered under an
|
||||
* SSH connection id still reports as `ssh:` and passes, which is fine — the predicate only
|
||||
* discounts the `runtime:` spelling. The consolidation moving the SSH path onto orcad is expected
|
||||
* to look exactly like that. The other route into `queriedHostIds` is separately fenced to
|
||||
* `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`.
|
||||
*/
|
||||
describe('the hosts a PTY inventory can report having queried', () => {
|
||||
afterEach(() => {
|
||||
sshProviders.clear()
|
||||
})
|
||||
|
||||
it('emits only local and ssh spellings, never a paired-runtime one', async () => {
|
||||
const listProcesses = vi.fn(async () => [])
|
||||
sshProviders.set('box-1', { listProcesses } as never)
|
||||
// A connection id shaped like an environment uuid still has to come back `ssh:`; the spelling
|
||||
// is what the gate keys on, so a `runtime:` id appearing here is the breakage that matters.
|
||||
sshProviders.set('a2478221-1d5c-4603-b8bf-b6b728eac9df', { listProcesses } as never)
|
||||
|
||||
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
|
||||
runtime: null
|
||||
} as unknown as PtyRuntimeControllerDeps)
|
||||
|
||||
expect(hostIds).toContain('ssh:a2478221-1d5c-4603-b8bf-b6b728eac9df')
|
||||
expect(new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))).toEqual(
|
||||
new Set(['local', 'ssh'])
|
||||
)
|
||||
})
|
||||
|
||||
it('drops a provider that threw rather than reporting its host as queried', async () => {
|
||||
sshProviders.set('box-live', { listProcesses: vi.fn(async () => []) } as never)
|
||||
sshProviders.set('box-down', {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('relay unavailable')
|
||||
})
|
||||
} as never)
|
||||
|
||||
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
|
||||
runtime: { markPtyLivenessUnverifiable: vi.fn() }
|
||||
} as unknown as PtyRuntimeControllerDeps)
|
||||
|
||||
expect(hostIds).toContain('ssh:box-live')
|
||||
expect(hostIds).not.toContain('ssh:box-down')
|
||||
})
|
||||
})
|
||||
@@ -115,6 +115,7 @@ export const gitWorktreeModuleMock = () => ({
|
||||
listWorktrees: listWorktreesMock,
|
||||
listWorktreesStrict: listWorktreesMock,
|
||||
listWorktreesSharedStrict: listWorktreesMock,
|
||||
listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesMock,
|
||||
describeCreatedWorktree: describeCreatedWorktreeMock,
|
||||
parseWorktreeList: parseWorktreeListMock,
|
||||
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
|
||||
|
||||
@@ -74,6 +74,7 @@ vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: listWorktreesMock,
|
||||
listWorktreesStrict: listWorktreesMock,
|
||||
listWorktreesSharedStrict: listWorktreesMock,
|
||||
listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesMock,
|
||||
describeCreatedWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
|
||||
addWorktree: addWorktreeMock,
|
||||
|
||||
@@ -25,9 +25,12 @@ import {
|
||||
type DetectedWorktreeMetadataPrune,
|
||||
type DetectedWorktreeSideEffectToken
|
||||
} from './detected-worktree-scan-cache'
|
||||
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
describeWorktreeScanFailure,
|
||||
loggedWorktreeListFailures,
|
||||
warnOnce
|
||||
} from './worktree-listing-diagnostics'
|
||||
import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta'
|
||||
|
||||
export async function listDetectedWorktreesForCapturedRepo(
|
||||
store: Store,
|
||||
@@ -40,9 +43,7 @@ export async function listDetectedWorktreesForCapturedRepo(
|
||||
providerAbort?.signal.aborted
|
||||
? ({ providerAbortStatus: providerAbort.status() } as const)
|
||||
: undefined
|
||||
const allMeta = isFolderRepo(repo)
|
||||
? undefined
|
||||
: readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
const allMeta = isFolderRepo(repo) ? undefined : readAllWorktreeMetaForRepo(store, repo)
|
||||
// Why: only the disconnected fallbacks read this, so keep parseWorktreeId over the whole host snapshot
|
||||
// off the connected path entirely.
|
||||
let cachedSshWorktreeMetaIndex: SshWorktreeMetaIndex | undefined
|
||||
@@ -160,16 +161,25 @@ export async function listDetectedWorktreesForCapturedRepo(
|
||||
`[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
// Why: retention alone leaves inert rows with no explanation; the cause rides with the listing.
|
||||
const unavailableReason = describeWorktreeScanFailure(err)
|
||||
if (repo.connectionId) {
|
||||
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex())
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees)
|
||||
worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees),
|
||||
unavailableReason
|
||||
}
|
||||
}
|
||||
return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] }
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: [],
|
||||
unavailableReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
|
||||
|
||||
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() },
|
||||
app: { getPath: () => '/tmp/orca-test' }
|
||||
}))
|
||||
vi.mock('../../../git/runner', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
const { listDetectedWorktreesForCapturedRepo } = await import('./detected-provider-listing')
|
||||
const { __resetDetectedWorktreeScanCacheForTests } = await import('./detected-worktree-scan-cache')
|
||||
const { _resetWorktreeScanCacheForTests } = await import('../../../git/worktree-scan-cache')
|
||||
const { isRegisteredWorktreePath, invalidateAuthorizedRootsCache } =
|
||||
await import('../../registered-worktree-roots-cache')
|
||||
|
||||
const REPO_PATH = '/workspace/repo'
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
} as Repo
|
||||
|
||||
const removeWorktreeLineage = vi.fn()
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
getRepo: () => repo,
|
||||
getRepos: () => [repo],
|
||||
getProjects: () => [],
|
||||
getSettings: () => ({}),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getProjectHostSetups: () => [],
|
||||
getWorktreeMeta: () => undefined,
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getAllWorkspaceLineage: () => ({}),
|
||||
removeWorktreeLineage,
|
||||
captureNativeLocalWorktreeMetadataScanExpectation: () => undefined
|
||||
} as never
|
||||
}
|
||||
|
||||
/** The field failure: wsl.exe exits 0xFFFFFFFF, says nothing on stderr, and git never ran. */
|
||||
function wslHostFailure(): Error {
|
||||
return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), {
|
||||
code: 4294967295,
|
||||
stdout: 'Error code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\r\n',
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
|
||||
async function listDetected(): Promise<DetectedWorktreeListResult> {
|
||||
const result = await listDetectedWorktreesForCapturedRepo(createStore(), repo, () => true)
|
||||
return result as DetectedWorktreeListResult
|
||||
}
|
||||
|
||||
describe('detected worktree listing authority', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
removeWorktreeLineage.mockReset()
|
||||
__resetDetectedWorktreeScanCacheForTests()
|
||||
_resetWorktreeScanCacheForTests()
|
||||
invalidateAuthorizedRootsCache()
|
||||
})
|
||||
|
||||
it('reports a failed scan as non-authoritative and prunes nothing', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(wslHostFailure())
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(false)
|
||||
expect(result.source).toBe('metadata-fallback')
|
||||
expect(result.worktrees).toEqual([])
|
||||
// Why: the retained rows must carry the cause, or the user sees inert worktrees with no explanation.
|
||||
expect(result.unavailableReason).toContain('Command failed: wsl.exe')
|
||||
// The destructive halves of a fresh scan must not run against a listing that failed.
|
||||
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the annotated wsl.exe diagnostic as the unavailable reason', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'wsl.exe host failure (distro "kali-linux"): There is no distribution with the supplied name.\r\nError code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\nCommand failed: wsl.exe -d kali-linux --exec sh -lc ...'
|
||||
),
|
||||
{ code: 4294967295, stdout: '', stderr: '' }
|
||||
)
|
||||
)
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(false)
|
||||
expect(result.unavailableReason).toBe(
|
||||
'wsl.exe host failure (distro "kali-linux"): There is no distribution with the supplied name. Error code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND'
|
||||
)
|
||||
})
|
||||
|
||||
// Why (measured on a real Windows host): under WSL the spawn cwd is the interop directory, so a
|
||||
// deleted guest repo fails as `bash: cd` exit 1 — not ENOENT — and must stay retained, not pruned.
|
||||
it('retains a WSL repo whose guest directory is gone, and says why', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('bash: line 1: cd: /home/neil/repo: No such file or directory'), {
|
||||
code: 1,
|
||||
stdout: '',
|
||||
stderr: 'bash: line 1: cd: /home/neil/repo: No such file or directory\n'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(false)
|
||||
expect(result.unavailableReason).toContain('No such file or directory')
|
||||
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an empty listing authoritative when the path is not a Git repo', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('Command failed: git worktree list'), {
|
||||
code: 128,
|
||||
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(true)
|
||||
expect(result.source).toBe('git')
|
||||
expect(result.worktrees).toEqual([])
|
||||
expect(result.unavailableReason).toBeUndefined()
|
||||
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an empty listing authoritative when the repo path is gone', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', stderr: '' })
|
||||
)
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(true)
|
||||
expect(result.source).toBe('git')
|
||||
expect(result.worktrees).toEqual([])
|
||||
})
|
||||
|
||||
it('stays authoritative for a healthy scan', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: `worktree ${REPO_PATH}\u0000HEAD abc\u0000branch refs/heads/main\u0000\u0000`,
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
const result = await listDetected()
|
||||
|
||||
expect(result.authoritative).toBe(true)
|
||||
expect(result.worktrees.map((worktree) => worktree.path)).toEqual([REPO_PATH])
|
||||
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { listRepoWorktrees } from '../../../repo-worktrees'
|
||||
import { listRepoWorktreesForDetectedScan } from '../../../repo-worktrees'
|
||||
import {
|
||||
getRegisteredWorktreeRootsRevision,
|
||||
registerWorktreeRootsForRepo
|
||||
@@ -111,7 +111,7 @@ export async function listDetectedGitWorktrees(
|
||||
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
|
||||
if (repo.connectionId || isFolderRepo(repo)) {
|
||||
return {
|
||||
gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions),
|
||||
gitWorktrees: await listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
|
||||
fresh: true
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function listDetectedGitWorktrees(
|
||||
: undefined
|
||||
const scan: DetectedWorktreeScan = {
|
||||
invalidated: false,
|
||||
promise: listRepoWorktrees(repo, localWorktreeGitOptions),
|
||||
promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
|
||||
sideEffectToken: { generation, authorizedRootsRevision },
|
||||
hygieneDue,
|
||||
...(metadataPruneExpectation
|
||||
|
||||
@@ -10,7 +10,9 @@ const { listRepoWorktreesMock, pruneLineageMock, pruneMetadataMock, registerWork
|
||||
registerWorktreeRootsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock }))
|
||||
vi.mock('../../../repo-worktrees', () => ({
|
||||
listRepoWorktreesForDetectedScan: listRepoWorktreesMock
|
||||
}))
|
||||
vi.mock('../../../project-runtime-git-options', () => ({
|
||||
getLocalProjectWorktreeGitOptions: () => ({})
|
||||
}))
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
warnOnce
|
||||
} from './worktree-listing-diagnostics'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
|
||||
import {
|
||||
readAllWorktreeMetaForHost,
|
||||
readAllWorktreeMetaForRepo
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
|
||||
const WORKTREE_LIST_ALL_CONCURRENCY = 8
|
||||
@@ -174,9 +177,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
if (!repo) {
|
||||
return []
|
||||
}
|
||||
const allMeta = repo.connectionId
|
||||
? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
: undefined
|
||||
const allMeta = repo.connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined
|
||||
const sshWorktreeMetaIndex = repo.connectionId
|
||||
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
|
||||
: new Map()
|
||||
@@ -226,7 +227,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
})
|
||||
}
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
|
||||
const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo)
|
||||
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
|
||||
.filter((worktree) => worktree.visible)
|
||||
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../sh
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
readWorktreeMetaForRepo,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
@@ -159,7 +159,7 @@ export function buildDetectedGitWorktrees(
|
||||
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
|
||||
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
|
||||
const meta =
|
||||
readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ??
|
||||
readWorktreeMetaForRepo(store, worktreeId, repo) ??
|
||||
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const detected = toDetectedWorktree({
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
readWorktreeMetaForHost,
|
||||
readWorktreeMetaForRepo,
|
||||
writeWorktreeMetaForHost
|
||||
} from '../../../persistence/host-qualified-worktree-meta'
|
||||
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
|
||||
@@ -44,7 +44,7 @@ export function resolveWorktreeMetaWithDiscoveryBackfill(
|
||||
// Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one.
|
||||
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
|
||||
const existing =
|
||||
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
|
||||
readWorktreeMetaForRepo(store, worktreeId, repo) ??
|
||||
getRepoOwnedWorktreeMeta(
|
||||
repo,
|
||||
worktreeId,
|
||||
|
||||
@@ -14,3 +14,23 @@ export function warnOnce(keySet: Set<string>, key: string, message: string, erro
|
||||
console.warn(message)
|
||||
}
|
||||
}
|
||||
|
||||
const SCAN_FAILURE_REASON_MAX_CHARS = 240
|
||||
|
||||
/**
|
||||
* The cause a retained-but-unscannable repo shows the user. The first two lines carry the
|
||||
* classifier's summary plus its `Wsl/Service/WSL_E_*` code; everything after is the raw command.
|
||||
*/
|
||||
export function describeWorktreeScanFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const summary = message
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(0, 2)
|
||||
.join(' ')
|
||||
const reason = summary.length > 0 ? summary : 'Worktree scan failed with no diagnostic.'
|
||||
return reason.length > SCAN_FAILURE_REASON_MAX_CHARS
|
||||
? `${reason.slice(0, SCAN_FAILURE_REASON_MAX_CHARS - 1)}…`
|
||||
: reason
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// Content-addressed store for the remainder of a bounded payload.
|
||||
//
|
||||
// Blobs are named by their sha256, so writing the same output twice costs one
|
||||
// file and re-import is idempotent. They live beside the journal (host-side
|
||||
// per-workspace state, never inside the user's working tree) and share the
|
||||
// epoch's retention: compaction prunes every blob no retained row references.
|
||||
|
||||
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write'
|
||||
|
||||
export const JOURNAL_BLOB_DIR = 'blobs'
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/
|
||||
|
||||
/** A digest arrives back from a row on disk, so it is untrusted by the time it
|
||||
* reaches the filesystem: anything but a bare sha256 could escape the store. */
|
||||
function blobPath(journalDir: string, digest: string): string | null {
|
||||
return DIGEST_PATTERN.test(digest) ? join(journalDir, JOURNAL_BLOB_DIR, digest) : null
|
||||
}
|
||||
|
||||
/** Persist `payload` under its digest. Returns the digest so the caller can
|
||||
* stamp it on the row it is about to append. */
|
||||
export async function putJournalBlob(
|
||||
journalDir: string,
|
||||
digest: string,
|
||||
payload: string
|
||||
): Promise<string> {
|
||||
const target = blobPath(journalDir, digest)
|
||||
if (!target) {
|
||||
throw new Error('refusing to write a journal blob under a name that is not a sha256 digest')
|
||||
}
|
||||
// Content addressing makes a rewrite pointless: identical digest, identical bytes.
|
||||
if (await pathExists(target)) {
|
||||
return digest
|
||||
}
|
||||
await mkdir(join(journalDir, JOURNAL_BLOB_DIR), { recursive: true })
|
||||
await writeFileDurable(durableWriteTempPath(target), target, payload)
|
||||
return digest
|
||||
}
|
||||
|
||||
export async function readJournalBlob(journalDir: string, digest: string): Promise<string | null> {
|
||||
const source = blobPath(journalDir, digest)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await readFile(source, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a blob written speculatively for a row that was rejected. */
|
||||
export async function removeJournalBlob(journalDir: string, digest: string): Promise<void> {
|
||||
const target = blobPath(journalDir, digest)
|
||||
if (target) {
|
||||
await rm(target, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every blob outside `retained`. Called from compaction, under the
|
||||
* current lease fence, after the snapshot is durable — so a crash mid-prune
|
||||
* leaves extra blobs rather than dangling references. */
|
||||
export async function pruneJournalBlobs(
|
||||
journalDir: string,
|
||||
retained: ReadonlySet<string>
|
||||
): Promise<number> {
|
||||
let removed = 0
|
||||
let names: string[]
|
||||
try {
|
||||
names = await readdir(join(journalDir, JOURNAL_BLOB_DIR))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
for (const name of names) {
|
||||
if (retained.has(name)) {
|
||||
continue
|
||||
}
|
||||
await rm(join(journalDir, JOURNAL_BLOB_DIR, name), { force: true }).catch(() => {})
|
||||
removed += 1
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function journalBlobFileSize(
|
||||
journalDir: string,
|
||||
digest: string
|
||||
): Promise<number | null> {
|
||||
const target = blobPath(journalDir, digest)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return (await stat(target)).size
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Where a journal goes when its close REJECTS.
|
||||
//
|
||||
// `AgentSessionJournal.close()` is deliberately retryable: a rejection does not
|
||||
// release the handle, and a second call is a real second attempt. Every caller
|
||||
// that did `close().catch(() => undefined)` and then threw or overwrote its map
|
||||
// entry defeated that contract — the store became unreachable with its SQLite
|
||||
// connection still open. On POSIX that is a silent leak; on Windows the open
|
||||
// handle blocks renaming or removing the journal directory outright.
|
||||
//
|
||||
// So a rejected close hands the journal here instead of dropping it, and host
|
||||
// teardown retries everything this holds. Retention is bounded by construction:
|
||||
// an entry leaves the set the moment its close fulfils, and a journal can be
|
||||
// retained only once because the set is keyed by identity.
|
||||
|
||||
/** Everything the registry needs; `AgentSessionJournal` satisfies it. */
|
||||
export type RetryableJournalClose = {
|
||||
close: () => Promise<void>
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
export class JournalCloseRetryRegistry {
|
||||
private readonly retained = new Set<RetryableJournalClose>()
|
||||
|
||||
/** Directories still held by a journal whose close has not fulfilled. */
|
||||
get pendingDirectories(): string[] {
|
||||
return [...this.retained].map((journal) => journal.directory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close it. Returns true when the handle is actually released; on a rejection
|
||||
* the journal is RETAINED for `retryAll` and the rejection is returned rather
|
||||
* than thrown, because every caller of this is already unwinding a different
|
||||
* failure it must not lose.
|
||||
*/
|
||||
async closeOrRetain(
|
||||
journal: RetryableJournalClose
|
||||
): Promise<{ closed: boolean; error?: unknown }> {
|
||||
try {
|
||||
await journal.close()
|
||||
this.retained.delete(journal)
|
||||
return { closed: true }
|
||||
} catch (error) {
|
||||
this.retained.add(journal)
|
||||
return { closed: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry every retained close. Ones that fulfil are dropped; ones that reject
|
||||
* stay retained and their rejections are returned for the caller to report. */
|
||||
async retryAll(): Promise<unknown[]> {
|
||||
const entries = [...this.retained]
|
||||
const failures: unknown[] = []
|
||||
for (const journal of entries) {
|
||||
const result = await this.closeOrRetain(journal)
|
||||
if (!result.closed) {
|
||||
failures.push(result.error)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide, because ownership of these handles is process-wide: the attach
|
||||
* path, the recovery wrapper and runtime teardown are separate call trees that
|
||||
* must all be able to reach the same orphan.
|
||||
*/
|
||||
export const agentSessionJournalCloseRetries = new JournalCloseRetryRegistry()
|
||||
@@ -1,206 +0,0 @@
|
||||
// Retention and compaction.
|
||||
//
|
||||
// The snapshot carries the retained tail with it, so publishing both is ONE
|
||||
// atomic write and there is no window where the folded state exists without the
|
||||
// rows a reconnecting client still needs. Truncating the log afterwards is
|
||||
// idempotent: a crash before it leaves the log a superset of the tail.
|
||||
//
|
||||
// The retained tail must cover the longest reconnect window Orca supports, or a
|
||||
// client that was merely asleep gets a full snapshot reload instead of a resume.
|
||||
|
||||
import { blobDigestsInBody, renderJournalState, type JournalReducerState } from './journal-reducer'
|
||||
import { pruneJournalBlobs } from './journal-blob-store'
|
||||
import {
|
||||
rewriteJournalLog,
|
||||
writeJournalSnapshotFile,
|
||||
type JournalSnapshotFile
|
||||
} from './journal-log-file'
|
||||
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
|
||||
|
||||
export type JournalCompactionPolicy = {
|
||||
/** Always keep at least this many rows, however old they are. */
|
||||
minTailRows: number
|
||||
/** Keep every row observed within this window. */
|
||||
retainTailMs: number
|
||||
/**
|
||||
* `window` honours `retainTailMs` outright. `budget-pressure` lets it yield:
|
||||
* the alternative is refusing the user's writes until the window ages out,
|
||||
* and the tail is only a resume optimization — compaction folds every shed
|
||||
* row into the snapshot before truncating the log, so a client that loses
|
||||
* its resume point reloads instead of losing conversation. Defaults to
|
||||
* `window`.
|
||||
*/
|
||||
retention?: 'window' | 'budget-pressure'
|
||||
}
|
||||
|
||||
/** Two hours of tail comfortably covers a phone that slept through a commute,
|
||||
* which is the longest reconnect Orca resumes rather than reloads. */
|
||||
export const DEFAULT_JOURNAL_COMPACTION_POLICY: JournalCompactionPolicy = {
|
||||
minTailRows: 512,
|
||||
retainTailMs: 2 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
export type JournalCompactionResult = {
|
||||
tailRows: JournalRow[]
|
||||
compactedThrough: number
|
||||
oldestSequence: number
|
||||
}
|
||||
|
||||
export async function compactJournal(input: {
|
||||
journalDir: string
|
||||
/** Parent quota root when compacting an in-directory staging journal. */
|
||||
physicalQuotaRoot?: string
|
||||
state: JournalReducerState
|
||||
tailRows: readonly JournalRow[]
|
||||
policy?: JournalCompactionPolicy
|
||||
now: number
|
||||
maxSessionBytes: number
|
||||
sessionId?: string
|
||||
}): Promise<JournalCompactionResult> {
|
||||
const policy = input.policy ?? DEFAULT_JOURNAL_COMPACTION_POLICY
|
||||
const retained = retainTail(input.tailRows, policy, input.now)
|
||||
const rendered = renderJournalState(input.state)
|
||||
const compactedThrough = input.state.lastSequence
|
||||
|
||||
const snapshot: JournalSnapshotFile = {
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch: input.state.epoch,
|
||||
compactedThrough,
|
||||
highestFence: input.state.highestFence,
|
||||
items: rendered.items,
|
||||
submissions: rendered.submissions,
|
||||
receipts: [...input.state.receipts.values()].map((receipt) => ({
|
||||
clientMessageId: receipt.clientMessageId,
|
||||
providerItemId: receipt.providerItemId,
|
||||
epoch: receipt.cursor.epoch,
|
||||
sequence: receipt.cursor.sequence,
|
||||
acceptedAt: receipt.acceptedAt
|
||||
})),
|
||||
aliases: [...input.state.aliases.entries()].map(([providerItemId, itemId]) => ({
|
||||
providerItemId,
|
||||
itemId
|
||||
})),
|
||||
tombstones: [...input.state.tombstones.entries()].map(([itemId, revision]) => ({
|
||||
itemId,
|
||||
revision
|
||||
})),
|
||||
appliedSettlementIds: [...input.state.appliedSettlementIds],
|
||||
tail: retained
|
||||
}
|
||||
|
||||
const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8')
|
||||
if (snapshotBytes > input.maxSessionBytes) {
|
||||
throw new AgentSessionJournalError(
|
||||
'journal_bound_exceeded',
|
||||
`agent-session journal snapshot reached its ${input.maxSessionBytes}-byte bound`
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId = input.sessionId ?? input.state.sessionId
|
||||
const quotaRoot = input.physicalQuotaRoot ?? input.journalDir
|
||||
const retainedLogBytes = retained.reduce(
|
||||
(total, row) => total + Buffer.byteLength(JSON.stringify(row), 'utf8') + 1,
|
||||
0
|
||||
)
|
||||
// Durable writes keep the old final alongside the new temp until rename.
|
||||
// Reserve the complete compaction peak up front so a later copy cannot leave
|
||||
// a half-published snapshot/log pair when the quota is tight.
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir: quotaRoot,
|
||||
sessionId,
|
||||
maxBytes: input.maxSessionBytes,
|
||||
peakAdditionalBytes: snapshotBytes + retainedLogBytes
|
||||
})
|
||||
await writeJournalSnapshotFile(input.journalDir, snapshot)
|
||||
await rewriteJournalLog(input.journalDir, retained)
|
||||
// Blobs are pruned last: a crash before this leaks bytes, whereas pruning
|
||||
// first would strand a snapshot pointing at a payload that no longer exists.
|
||||
// Recompute from exactly what the durable snapshot and retained log carry;
|
||||
// this preserves reused/pre-existing blobs while allowing stale payloads to
|
||||
// be pruned safely after both files are published.
|
||||
const retainedDigests = new Set<string>()
|
||||
for (const item of snapshot.items) {
|
||||
blobDigestsInBody(item.body, retainedDigests)
|
||||
}
|
||||
for (const row of retained) {
|
||||
if (row.kind === 'item') {
|
||||
blobDigestsInBody(row.body, retainedDigests)
|
||||
} else if (row.kind === 'lifecycle-batch') {
|
||||
for (const mutation of row.mutations) {
|
||||
if (mutation.kind === 'item') {
|
||||
blobDigestsInBody(mutation.body, retainedDigests)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await pruneJournalBlobs(input.journalDir, retainedDigests)
|
||||
|
||||
if ((await journalDirectoryBytes(quotaRoot)) > input.maxSessionBytes) {
|
||||
throw new AgentSessionJournalError(
|
||||
'journal_bound_exceeded',
|
||||
`agent-session journal for ${sessionId} exceeds its physical bound after compaction`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
tailRows: retained,
|
||||
compactedThrough,
|
||||
oldestSequence: retained[0]?.seq ?? compactedThrough + 1
|
||||
}
|
||||
}
|
||||
|
||||
function retainTail(
|
||||
rows: readonly JournalRow[],
|
||||
policy: JournalCompactionPolicy,
|
||||
now: number
|
||||
): JournalRow[] {
|
||||
if (rows.length <= policy.minTailRows) {
|
||||
return [...rows]
|
||||
}
|
||||
const floor = now - policy.retainTailMs
|
||||
const byAge = rows.findIndex((row) => row.ts >= floor)
|
||||
const byCount = rows.length - policy.minTailRows
|
||||
const start = byAge === -1 ? byCount : Math.min(byAge, byCount)
|
||||
if (policy.retention !== 'budget-pressure') {
|
||||
return rows.slice(start)
|
||||
}
|
||||
// Halve rather than empty: the newer half keeps live clients resuming, and
|
||||
// shedding at least one row guarantees the append that triggered this makes
|
||||
// progress instead of latching the session read-only.
|
||||
return rows.slice(Math.max(start, Math.ceil(rows.length / 2)))
|
||||
}
|
||||
|
||||
/** Only when the retention window would actually drop rows: inside it,
|
||||
* compaction rewrites an identical log, and doing that per append is a full
|
||||
* state serialization on the hot path. */
|
||||
export function journalTailIsReadyToCompact(
|
||||
tailRows: readonly JournalRow[],
|
||||
policy: JournalCompactionPolicy,
|
||||
now: number
|
||||
): boolean {
|
||||
if (tailRows.length <= policy.minTailRows * 2) {
|
||||
return false
|
||||
}
|
||||
return (tailRows[0]?.ts ?? now) < now - policy.retainTailMs
|
||||
}
|
||||
|
||||
/** The policy an append falls back to when the size bound would otherwise
|
||||
* refuse it: both floors that normally protect the tail step aside. */
|
||||
export function budgetPressurePolicy(policy: JournalCompactionPolicy): JournalCompactionPolicy {
|
||||
return { ...policy, minTailRows: 0, retention: 'budget-pressure' }
|
||||
}
|
||||
|
||||
/** Budget pressure may need to shed rows before the ordinary batching threshold.
|
||||
* Pass a `budget-pressure` policy, or a tail wholly inside the retention
|
||||
* window answers false and the size bound refuses every append until it ages
|
||||
* out — two hours of a session the user cannot write to. */
|
||||
export function journalTailCanShedRows(
|
||||
tailRows: readonly JournalRow[],
|
||||
policy: JournalCompactionPolicy,
|
||||
now: number
|
||||
): boolean {
|
||||
return retainTail(tailRows, policy, now).length < tailRows.length
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Corruption never deletes history. A journal that cannot be read end to end
|
||||
// keeps its intact prefix live and moves the unreadable remainder aside, so the
|
||||
// bytes stay on disk for inspection instead of being rebuilt into an empty epoch.
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
JOURNAL_SNAPSHOT_FILE,
|
||||
quarantineJournalRemainder,
|
||||
readJournalLog,
|
||||
rewriteJournalLog
|
||||
} from './journal-log-file'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { assertJournalPhysicalCapacity } from './journal-physical-quota'
|
||||
|
||||
/** Keep the readable prefix and set the unreadable suffix aside. */
|
||||
export async function quarantineCorruptSuffix(
|
||||
journalDir: string,
|
||||
retainedRows: readonly JournalRow[],
|
||||
remainder: string | undefined,
|
||||
quota?: { sessionId: string; maxBytes: number }
|
||||
): Promise<void> {
|
||||
if (remainder) {
|
||||
if (quota) {
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
...quota,
|
||||
peakAdditionalBytes: Buffer.byteLength(remainder, 'utf8')
|
||||
})
|
||||
}
|
||||
await quarantineJournalRemainder(journalDir, remainder)
|
||||
}
|
||||
if (quota) {
|
||||
const retainedBytes = retainedRows.reduce(
|
||||
(total, row) => total + Buffer.byteLength(JSON.stringify(row), 'utf8') + 1,
|
||||
0
|
||||
)
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
...quota,
|
||||
peakAdditionalBytes: retainedBytes
|
||||
})
|
||||
}
|
||||
await rewriteJournalLog(journalDir, retainedRows)
|
||||
}
|
||||
|
||||
/** Copy everything aside before a read-only journal is rebuilt under a newer
|
||||
* schema: those rows are unreadable to THIS build, not worthless. The
|
||||
* snapshot is preserved as raw bytes — a future-version snapshot does not
|
||||
* parse under this build's schema, and its bytes must survive verbatim. */
|
||||
export async function quarantineUnreadableSchema(
|
||||
journalDir: string,
|
||||
quota?: { sessionId: string; maxBytes: number }
|
||||
): Promise<void> {
|
||||
const snapshot = await readSnapshotBytes(journalDir)
|
||||
const log = await readJournalLog(journalDir)
|
||||
const preserved = [
|
||||
snapshot ?? '',
|
||||
log.rows.map((row) => JSON.stringify(row)).join('\n'),
|
||||
log.remainder ?? ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
if (preserved) {
|
||||
if (quota) {
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
...quota,
|
||||
peakAdditionalBytes: Buffer.byteLength(preserved, 'utf8')
|
||||
})
|
||||
}
|
||||
await quarantineJournalRemainder(journalDir, preserved)
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshotBytes(journalDir: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The disclosure row for lines that failed to parse. Skipped lines are lost
|
||||
* rows; counting them silently is the drop this exists to prevent. */
|
||||
export function malformedRowsDisclosure(count: number): {
|
||||
identity: { provider: 'orca'; clientMessageId: string }
|
||||
body: { kind: 'status'; text: string }
|
||||
} {
|
||||
const plural = count === 1 ? '' : 's'
|
||||
return {
|
||||
// One stable identity, so a reopen upserts the same row instead of adding one.
|
||||
identity: { provider: 'orca', clientMessageId: 'journal-malformed-lines' },
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: `${count} journal line${plural} could not be read and ${count === 1 ? 'was' : 'were'} skipped`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// A repair drops what it cannot replay, and says so.
|
||||
//
|
||||
// Two things make a suffix unreplayable: a row this build cannot parse, and a
|
||||
// sequence gap that makes every later row unanchored. The rejected suffix is
|
||||
// DELETED; the load reports `corrupt`, and recovery rebuilds the epoch from
|
||||
// provider history. Every case here asserts the same two halves: the live epoch
|
||||
// holds only the replayable prefix, AND the epoch stays anchored so nothing
|
||||
// replays a repaired journal as a clean timeline.
|
||||
|
||||
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 {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { openJournalDatabase } from './journal-database'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import { parseJournalRow, type JournalRow } from './journal-row-schema'
|
||||
import { loadJournal } from './journal-open'
|
||||
import type { openAgentSessionJournal } from './journal-store-factory'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function tick(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function body(value: string): AgentJournalItemBody {
|
||||
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
|
||||
}
|
||||
|
||||
function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: tick,
|
||||
mintEpoch: () => `epoch-${clock}`,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
async function withJournalDatabase(run: (db: Database.Database) => void): Promise<void> {
|
||||
const opened = openJournalDatabase(journalDatabaseFile(root))
|
||||
try {
|
||||
run(opened.db)
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** The row replay anchors on, parsed exactly as replay parses it. */
|
||||
function firstLiveRow(): Promise<JournalRow | null> {
|
||||
let row: JournalRow | null = null
|
||||
return withJournalDatabase((db) => {
|
||||
const stored = db.prepare('SELECT row_json FROM journal_rows ORDER BY seq LIMIT 1').get() as
|
||||
| { row_json: string }
|
||||
| undefined
|
||||
const parsed = stored ? parseJournalRow(stored.row_json) : null
|
||||
row = parsed?.ok ? parsed.row : null
|
||||
}).then(() => row)
|
||||
}
|
||||
|
||||
function liveSequences(): Promise<number[]> {
|
||||
let sequences: number[] = []
|
||||
return withJournalDatabase((db) => {
|
||||
sequences = (
|
||||
db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() as { seq: number }[]
|
||||
).map((row) => row.seq)
|
||||
}).then(() => sequences)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-repair-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('a malformed row', () => {
|
||||
it('keeps the readable prefix live and drops the rest of the epoch', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(item(0), body('readable'), { fence: 1 })
|
||||
await journal.appendItem(item(1), body('unreadable'), { fence: 1 })
|
||||
await journal.appendItem(item(2), body('after the fault'), { fence: 1 })
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3)
|
||||
})
|
||||
|
||||
const reopened = await open()
|
||||
expect(reopened.repair.malformedRows).toBe(1)
|
||||
// 1..2 is the surviving prefix; 3 is the disclosure the repair appends.
|
||||
expect(await liveSequences()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('discloses the line it could not read', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(item(0), body('readable'), { fence: 1 })
|
||||
await journal.appendItem(item(1), body('later'), { fence: 1 })
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2)
|
||||
})
|
||||
|
||||
const reopened = await open()
|
||||
const disclosure = reopened
|
||||
.snapshot()
|
||||
.items.map((entry) => entry.body)
|
||||
.find((entry) => entry.kind === 'status')
|
||||
expect(disclosure).toMatchObject({ kind: 'status' })
|
||||
expect(disclosure && 'text' in disclosure ? disclosure.text : '').toContain(
|
||||
'1 journal line could not be read'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a sequence gap', () => {
|
||||
it('drops every row after the hole and reports the epoch corrupt', async () => {
|
||||
const journal = await open()
|
||||
for (let ordinal = 0; ordinal < 5; ordinal += 1) {
|
||||
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
|
||||
}
|
||||
await journal.close()
|
||||
// Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves
|
||||
// 5 and 6 valid but unanchored.
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4)
|
||||
})
|
||||
|
||||
const reopened = await open()
|
||||
expect(await liveSequences()).toEqual([1, 2, 3])
|
||||
expect(reopened.repair).toEqual({ malformedRows: 0 })
|
||||
expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0'), body('m1')])
|
||||
})
|
||||
|
||||
// The prefix survives, so there is no emptied epoch to re-anchor and — a gap
|
||||
// costing no malformed row — no disclosure either. Without a durable marker
|
||||
// the next probe reads a contiguous anchored prefix and calls it clean, and
|
||||
// the rows the repair deleted are never asked for again.
|
||||
it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => {
|
||||
const journal = await open()
|
||||
for (let ordinal = 0; ordinal < 5; ordinal += 1) {
|
||||
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
|
||||
}
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4)
|
||||
})
|
||||
|
||||
const repaired = await open()
|
||||
await repaired.close()
|
||||
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
|
||||
|
||||
// Same policy the emptied-epoch repair takes: a session that writes into the
|
||||
// epoch owns it, and a later import must not replace rows the user has seen.
|
||||
const writable = await open()
|
||||
await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 })
|
||||
await writable.close()
|
||||
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
|
||||
})
|
||||
|
||||
// The disclosure is the repair talking about itself, not the session writing:
|
||||
// counting it as content would retire the marker the instant it was raised.
|
||||
it('is not settled by the repair disclosure it appends for a malformed row', async () => {
|
||||
const journal = await open()
|
||||
for (let ordinal = 0; ordinal < 3; ordinal += 1) {
|
||||
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
|
||||
}
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 3)
|
||||
})
|
||||
|
||||
const repaired = await open()
|
||||
expect(repaired.repair.malformedRows).toBe(1)
|
||||
await repaired.close()
|
||||
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a missing epoch row', () => {
|
||||
// Sequence 1 is the anchor for the whole epoch. Validating from the first row
|
||||
// that HAPPENS to remain declares the leftovers contiguous, and replay then
|
||||
// renders a repaired journal as a clean timeline.
|
||||
it('rejects the whole surviving range rather than declaring it contiguous', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(item(0), body('anchor'), { fence: 1 })
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'client-message-1',
|
||||
payloadFingerprint: 'fingerprint-1',
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'the user typed this' }]
|
||||
},
|
||||
fence: 1
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'client-message-1',
|
||||
state: 'accepted',
|
||||
providerIdentity: item(1),
|
||||
fence: 1
|
||||
})
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1)
|
||||
})
|
||||
|
||||
const reopened = await open()
|
||||
expect(reopened.repair).toEqual({ malformedRows: 0 })
|
||||
expect(reopened.snapshot().items).toEqual([])
|
||||
|
||||
// The epoch cannot be left row-less. An ordinary append would then take
|
||||
// sequence 1, and replay would call that non-epoch row a clean timeline.
|
||||
expect(await liveSequences()).toEqual([1])
|
||||
const anchor = await firstLiveRow()
|
||||
expect(anchor).toMatchObject({ kind: 'epoch', reason: 'unreconcilable_prefix' })
|
||||
})
|
||||
|
||||
// The repair epoch is a placeholder for history it could not rebuild. Left
|
||||
// clean it would end automatic recovery: the provider transcript is never
|
||||
// consulted again and the dropped rows never come back.
|
||||
it('keeps asking for provider history until the epoch has content of its own', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(item(0), body('anchor'), { fence: 1 })
|
||||
await journal.close()
|
||||
await withJournalDatabase((db) => {
|
||||
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1)
|
||||
})
|
||||
|
||||
const repaired = await open()
|
||||
await repaired.close()
|
||||
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
|
||||
|
||||
// A session that writes into the epoch owns it: its own rows are not a
|
||||
// repair placeholder, and a later import must not replace them.
|
||||
const writable = await open()
|
||||
await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 })
|
||||
await writable.close()
|
||||
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type ProviderHistoryItem,
|
||||
type ProviderHistoryWindow
|
||||
} from './journal-submission-reconciler'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
@@ -52,8 +52,10 @@ function userMessage(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
async function open() {
|
||||
return openAgentSessionJournal({
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: tick,
|
||||
@@ -89,6 +91,7 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -195,14 +198,8 @@ describe('crash between provider accept and journal commit', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the receipt after the row that minted it was compacted away', async () => {
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: tick,
|
||||
mintEpoch: () => `epoch-${clock}`,
|
||||
compaction: { minTailRows: 1, retainTailMs: 0 }
|
||||
})
|
||||
it('keeps the receipt across a reopen', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
payloadFingerprint: digestPayload('kept'),
|
||||
@@ -215,7 +212,7 @@ describe('crash between provider accept and journal commit', () => {
|
||||
providerIdentity: ACCEPTED_IDENTITY,
|
||||
fence: 1
|
||||
})
|
||||
await journal.compact()
|
||||
await journal.close()
|
||||
|
||||
const reopened = await open()
|
||||
expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY))
|
||||
|
||||
@@ -77,7 +77,7 @@ export function findSequenceGap(
|
||||
export function readJournalSince(
|
||||
source: {
|
||||
state: { epoch: string; lastSequence: number; oldestSequence: number }
|
||||
tailRows: readonly JournalRow[]
|
||||
rowsAfter: (afterSequence: number) => JournalRow[]
|
||||
readOnly: boolean
|
||||
},
|
||||
cursor: AgentJournalCursor,
|
||||
@@ -92,7 +92,7 @@ export function readJournalSince(
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
rows: source.tailRows.filter((row) => row.seq > resume.afterSequence),
|
||||
rows: source.rowsAfter(resume.afterSequence),
|
||||
cursor: currentCursor()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Table shape for one session's journal database.
|
||||
//
|
||||
// `journal_rows` is the append-only log; `journal_sessions` is the derived
|
||||
// projection, upserted in the SAME transaction as every row insert so the live
|
||||
// epoch and the rows that belong to it can never disagree. `journal_repairs`
|
||||
// carries at most one row per session: the standing demand for a rebuild a
|
||||
// partial repair leaves behind (see journal-repair-marker.ts).
|
||||
|
||||
/** DB shape version, carried in `PRAGMA user_version`. Independent of the row
|
||||
* body version (`JournalRow.v`): a newer build can change either alone.
|
||||
* v2 added `journal_repairs`; a build without it would replay a partially
|
||||
* repaired journal as clean, so it must latch read-only rather than write. */
|
||||
export const JOURNAL_DB_SCHEMA_VERSION = 2
|
||||
|
||||
export function createJournalTablesSql(): string {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS journal_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
epoch TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS journal_repairs (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
epoch TEXT NOT NULL,
|
||||
content_from INTEGER NOT NULL,
|
||||
repaired_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdtemp, rm, stat } 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 {
|
||||
JOURNAL_BUSY_TIMEOUT_MS,
|
||||
journalPragmaNumber,
|
||||
openJournalDatabase
|
||||
} from './journal-database'
|
||||
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import {
|
||||
deleteAllJournalRows,
|
||||
deleteJournalRowSuffix,
|
||||
insertJournalRow,
|
||||
readJournalEpochRows,
|
||||
readJournalRowsAfter,
|
||||
readJournalSessionEpoch,
|
||||
upsertJournalSessionRow
|
||||
} from './journal-row-table'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
let root: string
|
||||
let dbPath: string
|
||||
|
||||
function epochRow(seq: number, epoch = 'epoch-1'): JournalRow {
|
||||
return {
|
||||
kind: 'epoch',
|
||||
reason: 'session_created',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' },
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch,
|
||||
seq,
|
||||
fence: 0,
|
||||
ts: 1_700_000_000_000 + seq
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-db-'))
|
||||
dbPath = journalDatabaseFile(root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('journal database open', () => {
|
||||
it('creates both tables and reads back every load-bearing pragma', () => {
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
const tables = opened.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all()
|
||||
.map((entry) => (entry as { name: string }).name)
|
||||
expect(tables).toContain('journal_rows')
|
||||
expect(tables).toContain('journal_sessions')
|
||||
expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal')
|
||||
expect(journalPragmaNumber(opened.db, 'synchronous')).toBe(2)
|
||||
expect(journalPragmaNumber(opened.db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS)
|
||||
expect(journalPragmaNumber(opened.db, 'foreign_keys')).toBe(1)
|
||||
expect(journalPragmaNumber(opened.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION)
|
||||
expect(opened.readOnly).toBe(false)
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('latches read-only on a future user_version without touching the file', async () => {
|
||||
const seeded = openJournalDatabase(dbPath)
|
||||
upsertJournalSessionRow(seeded.db, 'session-1', 'epoch-1', 1)
|
||||
insertJournalRow(seeded.db, 'session-1', epochRow(1))
|
||||
seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`)
|
||||
seeded.db.close()
|
||||
const before = await stat(dbPath)
|
||||
|
||||
const latched = openJournalDatabase(dbPath)
|
||||
try {
|
||||
expect(latched.readOnly).toBe(true)
|
||||
expect(journalPragmaNumber(latched.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION + 5)
|
||||
expect(readJournalEpochRows(latched.db, 'session-1', 'epoch-1')).toHaveLength(1)
|
||||
expect(() => latched.db.exec("INSERT INTO journal_sessions VALUES ('x', 'y', 1)")).toThrow()
|
||||
} finally {
|
||||
latched.db.close()
|
||||
}
|
||||
expect((await stat(dbPath)).size).toBe(before.size)
|
||||
expect(journalPragmaNumber(openJournalDatabase(dbPath).db, 'user_version')).toBe(
|
||||
JOURNAL_DB_SCHEMA_VERSION + 5
|
||||
)
|
||||
})
|
||||
|
||||
// Site 1: the raw connection is owned by the open call until it returns.
|
||||
it('closes the raw connection when schema setup throws', async () => {
|
||||
const failing = join(root, 'nested', 'journal.db')
|
||||
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-'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('journal row statements', () => {
|
||||
it('serves replay, resume, discard and suffix truncation from the primary key', () => {
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
const { db } = opened
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
for (let seq = 1; seq <= 5; seq += 1) {
|
||||
insertJournalRow(db, 'session-1', epochRow(seq))
|
||||
}
|
||||
insertJournalRow(db, 'session-1', epochRow(1, 'epoch-old'))
|
||||
upsertJournalSessionRow(db, 'session-1', 'epoch-1', 42)
|
||||
db.exec('COMMIT')
|
||||
|
||||
expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1')
|
||||
expect(readJournalSessionEpoch(db, 'absent')).toBeNull()
|
||||
expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([
|
||||
1, 2, 3, 4, 5
|
||||
])
|
||||
expect(readJournalRowsAfter(db, 'session-1', 'epoch-1', 3).map((row) => row.seq)).toEqual([
|
||||
4, 5
|
||||
])
|
||||
|
||||
// The rejected suffix leaves `journal_rows`, scoped to its own epoch.
|
||||
expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2)
|
||||
expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(1)
|
||||
|
||||
deleteAllJournalRows(db)
|
||||
expect(readJournalEpochRows(db, 'session-1', 'epoch-1')).toHaveLength(0)
|
||||
expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(0)
|
||||
expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1')
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a duplicate sequence inside one epoch', () => {
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
insertJournalRow(opened.db, 'session-1', epochRow(1))
|
||||
expect(() => insertJournalRow(opened.db, 'session-1', epochRow(1))).toThrow()
|
||||
insertJournalRow(opened.db, 'session-1', epochRow(1, 'epoch-2'))
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('upserts the session projection in place', () => {
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
upsertJournalSessionRow(opened.db, 'session-1', 'epoch-1', 1)
|
||||
upsertJournalSessionRow(opened.db, 'session-1', 'epoch-2', 2)
|
||||
expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-2')
|
||||
expect(
|
||||
opened.db.prepare('SELECT count(*) AS total FROM journal_sessions').get()
|
||||
).toMatchObject({ total: 1 })
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema creation', () => {
|
||||
// Creating the tables outside the migration transaction left a v2-shaped
|
||||
// database still reporting version 0, which an older build does not latch
|
||||
// read-only: it stamps its own version on and writes through v1 SQL.
|
||||
it('publishes no table until the version bump commits with it', () => {
|
||||
const original = Database.prototype.pragma
|
||||
const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function (
|
||||
this: Database.Database,
|
||||
sql: string,
|
||||
options?: { simple?: boolean }
|
||||
) {
|
||||
if (sql.startsWith('user_version =')) {
|
||||
throw new Error('crash before the version is published')
|
||||
}
|
||||
return original.call(this, sql, options)
|
||||
})
|
||||
|
||||
expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published')
|
||||
pragma.mockRestore()
|
||||
|
||||
const inspected = new Database(dbPath)
|
||||
try {
|
||||
expect(inspected.pragma('user_version', { simple: true })).toBe(0)
|
||||
expect(
|
||||
inspected
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journal_rows'")
|
||||
.get()
|
||||
).toBeUndefined()
|
||||
} finally {
|
||||
inspected.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
// Opening one session's journal database.
|
||||
//
|
||||
// `PRAGMA user_version` is read FIRST, on a connection that has set no
|
||||
// persistent pragma and run no DDL: a future-schema database must be left
|
||||
// byte-identical, and `journal_mode = WAL` writes the file header.
|
||||
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files'
|
||||
import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
|
||||
|
||||
export const JOURNAL_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
export type OpenJournalDatabase = {
|
||||
db: Database.Database
|
||||
/** A newer `user_version` was met: this build reads and never writes. */
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export function journalPragmaNumber(db: Database.Database, name: string): number {
|
||||
return Number(db.pragma(name, { simple: true }) ?? 0)
|
||||
}
|
||||
|
||||
export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
|
||||
const probe = new Database(dbPath)
|
||||
let stored: number
|
||||
try {
|
||||
stored = journalPragmaNumber(probe, 'user_version')
|
||||
} catch (error) {
|
||||
probe.close()
|
||||
throw error
|
||||
}
|
||||
if (stored > JOURNAL_DB_SCHEMA_VERSION) {
|
||||
probe.close()
|
||||
return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true }
|
||||
}
|
||||
let transferred = false
|
||||
try {
|
||||
configureJournalPragmas(probe)
|
||||
createJournalSchema(probe, stored)
|
||||
hardenSqliteDatabaseFiles(dbPath)
|
||||
const opened = { db: probe, readOnly: false }
|
||||
transferred = true
|
||||
return opened
|
||||
} finally {
|
||||
if (!transferred) {
|
||||
probe.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function configureJournalPragmas(db: Database.Database): void {
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma(`busy_timeout = ${JOURNAL_BUSY_TIMEOUT_MS}`)
|
||||
db.pragma('foreign_keys = ON')
|
||||
// Why FULL rather than the house NORMAL: the write-ahead submission row must
|
||||
// survive a power loss before the adapter dispatches anything, and NORMAL in
|
||||
// WAL mode does not fsync at commit.
|
||||
db.pragma('synchronous = FULL')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: it stamped its own version on and wrote
|
||||
* through SQL for a schema it did not have.
|
||||
*/
|
||||
function createJournalSchema(db: Database.Database, stored: number): void {
|
||||
if (stored >= JOURNAL_DB_SCHEMA_VERSION) {
|
||||
return
|
||||
}
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
db.exec(createJournalTablesSql())
|
||||
db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`)
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,21 @@ import type {
|
||||
AgentJournalCursor,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalCompactionPolicy } from './journal-compaction'
|
||||
import { quarantineUnreadableSchema } from './journal-corruption-quarantine'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import { publishNewEpoch } from './journal-epoch-rollover'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import type { AgentJournalEpochReason } from './journal-row-schema'
|
||||
import {
|
||||
assertJournalFence,
|
||||
assertJournalWritable,
|
||||
type JournalAppendBudget
|
||||
} from './journal-write-guards'
|
||||
import { assertJournalFence, assertJournalWritable } from './journal-write-guards'
|
||||
|
||||
export class JournalEpochController {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
identity: AgentSessionJournalIdentity
|
||||
journalDir: string
|
||||
budget: JournalAppendBudget
|
||||
compaction: JournalCompactionPolicy
|
||||
now: () => number
|
||||
mintEpoch: () => string
|
||||
serialize: <T>(run: () => Promise<T>) => Promise<T>
|
||||
database: () => { db: Database.Database }
|
||||
readOnly: () => boolean
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
highestFence: () => number
|
||||
@@ -32,33 +25,33 @@ export class JournalEpochController {
|
||||
}
|
||||
) {}
|
||||
|
||||
async start(reason: AgentJournalEpochReason, fence: number): Promise<void> {
|
||||
this.deps.adopt(
|
||||
await publishNewEpoch({
|
||||
journalDir: this.deps.journalDir,
|
||||
sessionId: this.deps.identity.sessionId,
|
||||
providerHandle: this.deps.identity.providerHandle,
|
||||
epoch: this.deps.mintEpoch(),
|
||||
reason,
|
||||
fence,
|
||||
now: this.deps.now(),
|
||||
maxSessionBytes: this.deps.budget.maxSessionBytes
|
||||
})
|
||||
)
|
||||
start(reason: AgentJournalEpochReason, fence: number): void {
|
||||
publishNewEpoch({
|
||||
db: this.deps.database().db,
|
||||
sessionId: this.deps.identity.sessionId,
|
||||
providerHandle: this.deps.identity.providerHandle,
|
||||
epoch: this.deps.mintEpoch(),
|
||||
reason,
|
||||
fence,
|
||||
now: this.deps.now(),
|
||||
onPublished: this.deps.adopt
|
||||
})
|
||||
}
|
||||
|
||||
async roll(reason: AgentJournalEpochReason, fence: number): Promise<AgentJournalCursor> {
|
||||
if (reason !== 'schema_unreadable') {
|
||||
/**
|
||||
* Every reason takes the same writable guard. A latched store refuses a roll
|
||||
* like any other write, and `schema_unreadable` has no production caller.
|
||||
*
|
||||
* Serialized like every other write, so the discard cannot land between an
|
||||
* admitted append's sequence assignment and its commit.
|
||||
*/
|
||||
roll(reason: AgentJournalEpochReason, fence: number): Promise<AgentJournalCursor> {
|
||||
return this.deps.serialize(async () => {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId)
|
||||
} else if (this.deps.readOnly()) {
|
||||
await quarantineUnreadableSchema(this.deps.journalDir, {
|
||||
sessionId: this.deps.identity.sessionId,
|
||||
maxBytes: this.deps.budget.maxSessionBytes
|
||||
})
|
||||
}
|
||||
await this.start(reason, fence)
|
||||
this.deps.setReadOnly(false)
|
||||
return this.deps.cursor()
|
||||
this.start(reason, fence)
|
||||
this.deps.setReadOnly(false)
|
||||
return this.deps.cursor()
|
||||
})
|
||||
}
|
||||
|
||||
replace(
|
||||
@@ -69,17 +62,15 @@ export class JournalEpochController {
|
||||
return this.deps.serialize(async () => {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId)
|
||||
assertJournalFence(fence, this.deps.highestFence())
|
||||
await replaceJournalEpoch({
|
||||
journalDir: this.deps.journalDir,
|
||||
replaceJournalEpoch({
|
||||
db: this.deps.database().db,
|
||||
identity: this.deps.identity,
|
||||
reason,
|
||||
fence,
|
||||
items,
|
||||
budget: this.deps.budget.fork(),
|
||||
compaction: this.deps.compaction,
|
||||
now: this.deps.now,
|
||||
mintEpoch: this.deps.mintEpoch,
|
||||
onSnapshotPublished: this.deps.adopt
|
||||
onPublished: this.deps.adopt
|
||||
})
|
||||
return this.deps.cursor()
|
||||
})
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { mkdtemp, readdir, rm } from 'node:fs/promises'
|
||||
// Republishing an epoch is ONE transaction.
|
||||
|
||||
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 {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { DEFAULT_JOURNAL_COMPACTION_POLICY } from './journal-compaction'
|
||||
import { openJournalDatabase, type OpenJournalDatabase } from './journal-database'
|
||||
import { replaceJournalEpoch } from './journal-epoch-replacement'
|
||||
import { putJournalBlob, readJournalBlob } from './journal-blob-store'
|
||||
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { journalDirectoryBytes } from './journal-physical-quota'
|
||||
import { JournalAppendBudget } from './journal-write-guards'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import { readJournalEpochRows, readJournalSessionEpoch } from './journal-row-table'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
@@ -24,150 +25,79 @@ const IDENTITY: AgentSessionJournalIdentity = {
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
let database: OpenJournalDatabase
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function now(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
function toolBody(output: ReturnType<typeof boundPayload>): AgentJournalItemBody {
|
||||
return {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: {},
|
||||
state: 'completed',
|
||||
output
|
||||
}
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
describe('journal epoch replacement', () => {
|
||||
it('publishes one observable replacement and prunes stale root blobs afterward', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8 }
|
||||
const stalePayload = 'stale'.repeat(1_000)
|
||||
const retainedPayload = 'retained'.repeat(1_000)
|
||||
const stale = boundPayload(stalePayload, limits)
|
||||
const retained = boundPayload(retainedPayload, limits)
|
||||
const published: unknown[] = []
|
||||
await putJournalBlob(root, stale.digest, stalePayload)
|
||||
function replace(input: {
|
||||
items: Parameters<typeof replaceJournalEpoch>[0]['items']
|
||||
onPublished?: (loaded: JournalLoad) => void
|
||||
}): void {
|
||||
replaceJournalEpoch({
|
||||
db: database.db,
|
||||
identity: IDENTITY,
|
||||
reason: 'legacy_import',
|
||||
fence: 1,
|
||||
items: input.items,
|
||||
now,
|
||||
mintEpoch: () => `epoch-${clock}`,
|
||||
onPublished: input.onPublished ?? (() => undefined)
|
||||
})
|
||||
}
|
||||
|
||||
await replaceJournalEpoch({
|
||||
journalDir: root,
|
||||
identity: IDENTITY,
|
||||
reason: 'handle_forked',
|
||||
fence: 2,
|
||||
items: [
|
||||
{
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
body: toolBody(retained),
|
||||
blobs: [{ digest: retained.digest, payload: retainedPayload }]
|
||||
}
|
||||
],
|
||||
budget: new JournalAppendBudget(IDENTITY.sessionId, {
|
||||
...limits,
|
||||
maxSessionBytes: 512 * 1024
|
||||
}),
|
||||
compaction: DEFAULT_JOURNAL_COMPACTION_POLICY,
|
||||
now,
|
||||
mintEpoch: () => 'epoch-new',
|
||||
onSnapshotPublished: (loaded) => published.push(loaded)
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-'))
|
||||
clock = 1_000
|
||||
database = openJournalDatabase(journalDatabaseFile(root))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
database.db.close()
|
||||
} catch {
|
||||
// Already closed by the case.
|
||||
}
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('journal epoch replacement', () => {
|
||||
it('publishes one observable replacement', () => {
|
||||
const published: JournalLoad[] = []
|
||||
|
||||
replace({
|
||||
items: [{ identity: item(1), body: { kind: 'status', text: 'republished' } }],
|
||||
onPublished: (loaded) => published.push(loaded)
|
||||
})
|
||||
|
||||
expect(published).toHaveLength(1)
|
||||
expect(await readJournalBlob(root, stale.digest)).toBeNull()
|
||||
expect(await readJournalBlob(root, retained.digest)).toBe(retainedPayload)
|
||||
expect((published[0] as { sizeBytes: number }).sizeBytes).toBe(
|
||||
await journalDirectoryBytes(root)
|
||||
)
|
||||
const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId)
|
||||
expect(epoch).toBe(published[0]?.state.epoch)
|
||||
expect(readJournalEpochRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps root blobs and reports no publication when replacement never becomes authoritative', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8, maxSessionBytes: 6_000 }
|
||||
const stalePayload = 'stale'.repeat(500)
|
||||
const stale = boundPayload(stalePayload, limits)
|
||||
const published: unknown[] = []
|
||||
await putJournalBlob(root, stale.digest, stalePayload)
|
||||
it('discards every superseded row in the same transaction', async () => {
|
||||
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
|
||||
await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 })
|
||||
await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 })
|
||||
const before = journal.epoch
|
||||
|
||||
await expect(
|
||||
replaceJournalEpoch({
|
||||
journalDir: root,
|
||||
identity: IDENTITY,
|
||||
reason: 'handle_forked',
|
||||
fence: 2,
|
||||
items: [
|
||||
{
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'x'.repeat(10_000) }]
|
||||
}
|
||||
}
|
||||
],
|
||||
budget: new JournalAppendBudget(IDENTITY.sessionId, limits),
|
||||
compaction: DEFAULT_JOURNAL_COMPACTION_POLICY,
|
||||
now,
|
||||
mintEpoch: () => 'epoch-new',
|
||||
onSnapshotPublished: (loaded) => published.push(loaded)
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
await journal.replaceEpochItems('legacy_import', 1, [
|
||||
{ identity: item(9), body: { kind: 'status', text: 'republished' } }
|
||||
])
|
||||
|
||||
expect(published).toHaveLength(0)
|
||||
expect(await readJournalBlob(root, stale.digest)).toBe(stalePayload)
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
|
||||
})
|
||||
|
||||
it('charges replacement blobs cumulatively and rolls back staging on quota refusal', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8, maxSessionBytes: 7_000 }
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
limits,
|
||||
autoCompact: false,
|
||||
now,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
const existingPayload = 'existing'.repeat(250)
|
||||
const existing = boundPayload(existingPayload, limits)
|
||||
await journal.appendItemWithBlobs(
|
||||
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
|
||||
toolBody(existing),
|
||||
[{ digest: existing.digest, payload: existingPayload }],
|
||||
{ fence: 1 }
|
||||
)
|
||||
|
||||
const replacementPayload = 'replacement'.repeat(200)
|
||||
const replacement = boundPayload(replacementPayload, limits)
|
||||
const secondPayload = 'second'.repeat(200)
|
||||
const second = boundPayload(secondPayload, limits)
|
||||
await expect(
|
||||
journal.replaceEpochItems('handle_forked', 2, [
|
||||
{
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 },
|
||||
body: toolBody(replacement),
|
||||
blobs: [{ digest: replacement.digest, payload: replacementPayload }]
|
||||
},
|
||||
{
|
||||
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 2 },
|
||||
body: toolBody(second),
|
||||
blobs: [{ digest: second.digest, payload: secondPayload }]
|
||||
}
|
||||
])
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
|
||||
expect(journal.epoch).toMatch(/^epoch-/)
|
||||
expect(await readJournalBlob(root, existing.digest)).toBe(existingPayload)
|
||||
expect(await readJournalBlob(root, replacement.digest)).toBeNull()
|
||||
expect(await readJournalBlob(root, second.digest)).toBeNull()
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
|
||||
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
|
||||
expect(journal.epoch).not.toBe(before)
|
||||
expect(readJournalEpochRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0)
|
||||
expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([
|
||||
{ kind: 'status', text: 'republished' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,298 +1,85 @@
|
||||
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { copyFileDurable } from '../../durable-file-write'
|
||||
// Republishing a live item set into a fresh epoch.
|
||||
//
|
||||
// One transaction: discard every row, insert the epoch row plus the replacement
|
||||
// items, move the session projection, and retire any repair marker — this
|
||||
// republished history is exactly what the marker was holding out for.
|
||||
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { compactJournal, type JournalCompactionPolicy } from './journal-compaction'
|
||||
import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE, appendJournalRows } from './journal-log-file'
|
||||
import {
|
||||
applyJournalRow,
|
||||
blobDigestsInBody,
|
||||
createJournalReducerState,
|
||||
referencedBlobDigests,
|
||||
type JournalReducerState
|
||||
} from './journal-reducer'
|
||||
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
|
||||
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
|
||||
import { journalRowByteLength } from './journal-row-schema'
|
||||
import { assertJournalFence, type JournalAppendBudget } from './journal-write-guards'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
|
||||
import { clearJournalRepairMarker } from './journal-repair-marker'
|
||||
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
|
||||
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
|
||||
import {
|
||||
JOURNAL_BLOB_DIR,
|
||||
journalBlobFileSize,
|
||||
putJournalBlob,
|
||||
pruneJournalBlobs,
|
||||
removeJournalBlob
|
||||
} from './journal-blob-store'
|
||||
deleteAllJournalRows,
|
||||
insertJournalRow,
|
||||
upsertJournalSessionRow
|
||||
} from './journal-row-table'
|
||||
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
|
||||
import { assertJournalFence } from './journal-write-guards'
|
||||
|
||||
export type JournalReplacementItem = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
blobs?: readonly { digest: string; payload: string }[]
|
||||
observedAt?: number
|
||||
}
|
||||
|
||||
export async function replaceJournalEpoch(input: {
|
||||
journalDir: string
|
||||
export function replaceJournalEpoch(input: {
|
||||
db: Database.Database
|
||||
identity: AgentSessionJournalIdentity
|
||||
reason: AgentJournalEpochReason
|
||||
fence: number
|
||||
items: readonly JournalReplacementItem[]
|
||||
budget: JournalAppendBudget
|
||||
compaction: JournalCompactionPolicy
|
||||
now: () => number
|
||||
mintEpoch: () => string
|
||||
onSnapshotPublished: (loaded: JournalLoad) => void
|
||||
}): Promise<void> {
|
||||
const stagingDir = await mkdtemp(join(input.journalDir, '.epoch-replacement-'))
|
||||
const stagedBlobDigests = new Set<string>()
|
||||
const publishedBlobDigests: string[] = []
|
||||
let snapshotPublished = false
|
||||
let adoptionReported = false
|
||||
let publishedLoad: JournalLoad | null = null
|
||||
/** Called the instant the transaction commits, before any fallible follow-up. */
|
||||
onPublished: (loaded: JournalLoad) => void
|
||||
}): void {
|
||||
const epoch = input.mintEpoch()
|
||||
const state = createJournalReducerState(input.identity.sessionId, epoch)
|
||||
const epochRow: JournalRow = {
|
||||
kind: 'epoch',
|
||||
reason: input.reason,
|
||||
providerHandle: input.identity.providerHandle,
|
||||
...journalRowBase(epoch, 1, input.fence, input.now())
|
||||
}
|
||||
const rows: JournalRow[] = [epochRow]
|
||||
applyJournalRow(state, epochRow)
|
||||
for (const item of input.items) {
|
||||
const row = buildJournalItemRow({
|
||||
state,
|
||||
identity: item.identity,
|
||||
body: item.body,
|
||||
seq: state.lastSequence + 1,
|
||||
fence: input.fence,
|
||||
ts: item.observedAt ?? input.now()
|
||||
})
|
||||
assertJournalFence(row.fence, state.highestFence)
|
||||
applyJournalRow(state, row)
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
input.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const epoch = input.mintEpoch()
|
||||
const state = createJournalReducerState(input.identity.sessionId, epoch)
|
||||
const epochRow: JournalRow = {
|
||||
kind: 'epoch',
|
||||
reason: input.reason,
|
||||
providerHandle: input.identity.providerHandle,
|
||||
...journalRowBase(epoch, 1, input.fence, input.now())
|
||||
}
|
||||
const rows: JournalRow[] = [epochRow]
|
||||
applyJournalRow(state, epochRow)
|
||||
let sizeBytes = journalRowByteLength(epochRow)
|
||||
await assertStagingCapacity(input, sizeBytes)
|
||||
await appendJournalRows(stagingDir, [epochRow])
|
||||
|
||||
for (const item of input.items) {
|
||||
sizeBytes += await stageReplacementBlobs({
|
||||
journalDir: input.journalDir,
|
||||
stagingDir,
|
||||
identity: input.identity,
|
||||
budget: input.budget,
|
||||
stagedBlobDigests,
|
||||
blobs: item.blobs ?? []
|
||||
})
|
||||
const appendTime = input.now()
|
||||
const row = buildJournalItemRow({
|
||||
state,
|
||||
identity: item.identity,
|
||||
body: item.body,
|
||||
seq: state.lastSequence + 1,
|
||||
fence: input.fence,
|
||||
ts: item.observedAt ?? appendTime
|
||||
})
|
||||
assertJournalFence(row.fence, state.highestFence)
|
||||
input.budget.assert(row, appendTime, sizeBytes)
|
||||
await assertStagingCapacity(input, journalRowByteLength(row))
|
||||
await appendJournalRows(stagingDir, [row])
|
||||
applyJournalRow(state, row)
|
||||
rows.push(row)
|
||||
sizeBytes += journalRowByteLength(row)
|
||||
}
|
||||
|
||||
const compacted = await compactJournal({
|
||||
journalDir: stagingDir,
|
||||
physicalQuotaRoot: input.journalDir,
|
||||
state,
|
||||
tailRows: rows,
|
||||
policy: input.compaction,
|
||||
now: input.now(),
|
||||
maxSessionBytes: input.budget.maxSessionBytes
|
||||
})
|
||||
// All destination publishes use durable temp files while the staging
|
||||
// source and existing finals remain present. Reserve the whole publication
|
||||
// peak before touching the live epoch so a later file cannot fail halfway
|
||||
// through replacement.
|
||||
const stagedSnapshotBytes = (await stat(join(stagingDir, JOURNAL_SNAPSHOT_FILE))).size
|
||||
const stagedLogBytes = (await stat(join(stagingDir, JOURNAL_LOG_FILE))).size
|
||||
let stagedPublishBytes = stagedSnapshotBytes + stagedLogBytes
|
||||
for (const digest of stagedBlobDigests) {
|
||||
stagedPublishBytes += (await stat(join(stagingDir, JOURNAL_BLOB_DIR, digest))).size
|
||||
}
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir: input.journalDir,
|
||||
sessionId: input.identity.sessionId,
|
||||
maxBytes: input.budget.maxSessionBytes,
|
||||
peakAdditionalBytes: stagedPublishBytes
|
||||
})
|
||||
for (const digest of stagedBlobDigests) {
|
||||
if (
|
||||
await publishPreparedBlob(
|
||||
stagingDir,
|
||||
input.journalDir,
|
||||
digest,
|
||||
input.identity.sessionId,
|
||||
input.budget.maxSessionBytes
|
||||
)
|
||||
) {
|
||||
publishedBlobDigests.push(digest)
|
||||
}
|
||||
}
|
||||
await publishPreparedFile(
|
||||
stagingDir,
|
||||
input.journalDir,
|
||||
JOURNAL_SNAPSHOT_FILE,
|
||||
input.identity.sessionId,
|
||||
input.budget.maxSessionBytes
|
||||
)
|
||||
snapshotPublished = true
|
||||
state.oldestSequence = compacted.oldestSequence
|
||||
publishedLoad = {
|
||||
state,
|
||||
tailRows: compacted.tailRows,
|
||||
compactedThrough: compacted.compactedThrough,
|
||||
readOnly: false,
|
||||
corrupt: false,
|
||||
malformedRows: 0,
|
||||
sizeBytes: 0
|
||||
}
|
||||
await publishPreparedFile(
|
||||
stagingDir,
|
||||
input.journalDir,
|
||||
JOURNAL_LOG_FILE,
|
||||
input.identity.sessionId,
|
||||
input.budget.maxSessionBytes
|
||||
)
|
||||
await pruneJournalBlobs(
|
||||
input.journalDir,
|
||||
replacementRetainedBlobDigests(state, compacted.tailRows)
|
||||
)
|
||||
} finally {
|
||||
if (!snapshotPublished) {
|
||||
for (const digest of publishedBlobDigests) {
|
||||
await removeJournalBlob(input.journalDir, digest)
|
||||
}
|
||||
}
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
if (snapshotPublished && publishedLoad && !adoptionReported) {
|
||||
adoptionReported = true
|
||||
input.onSnapshotPublished({
|
||||
...publishedLoad,
|
||||
sizeBytes: await journalDirectoryBytes(input.journalDir)
|
||||
})
|
||||
deleteAllJournalRows(input.db)
|
||||
clearJournalRepairMarker(input.db, input.identity.sessionId)
|
||||
for (const row of rows) {
|
||||
insertJournalRow(input.db, input.identity.sessionId, row)
|
||||
}
|
||||
upsertJournalSessionRow(input.db, input.identity.sessionId, epoch, epochRow.ts)
|
||||
input.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
input.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function replacementRetainedBlobDigests(
|
||||
state: JournalReducerState,
|
||||
tailRows: readonly JournalRow[]
|
||||
): Set<string> {
|
||||
const retained = referencedBlobDigests(state)
|
||||
for (const row of tailRows) {
|
||||
if (row.kind === 'item') {
|
||||
blobDigestsInBody(row.body, retained)
|
||||
} else if (row.kind === 'lifecycle-batch') {
|
||||
for (const mutation of row.mutations) {
|
||||
if (mutation.kind === 'item') {
|
||||
blobDigestsInBody(mutation.body, retained)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return retained
|
||||
}
|
||||
|
||||
async function stageReplacementBlobs(input: {
|
||||
journalDir: string
|
||||
stagingDir: string
|
||||
identity: AgentSessionJournalIdentity
|
||||
budget: JournalAppendBudget
|
||||
stagedBlobDigests: Set<string>
|
||||
blobs: readonly { digest: string; payload: string }[]
|
||||
}): Promise<number> {
|
||||
const toStage: { digest: string; payload: string; bytes: number }[] = []
|
||||
const unique = new Map(input.blobs.map((blob) => [blob.digest, blob]))
|
||||
for (const blob of unique.values()) {
|
||||
if (input.stagedBlobDigests.has(blob.digest)) {
|
||||
continue
|
||||
}
|
||||
if ((await journalBlobFileSize(input.journalDir, blob.digest)) !== null) {
|
||||
continue
|
||||
}
|
||||
const bytes = Buffer.byteLength(blob.payload, 'utf8')
|
||||
toStage.push({ ...blob, bytes })
|
||||
}
|
||||
// Reserve all new payloads together. The staging directory lives under the
|
||||
// journal root, so the capacity check includes existing session bytes and
|
||||
// every other .epoch-replacement-* directory already present.
|
||||
const stagedBytes = toStage.reduce((total, blob) => total + blob.bytes, 0)
|
||||
await assertStagingCapacity(input, stagedBytes)
|
||||
for (const blob of toStage) {
|
||||
await putJournalBlob(input.stagingDir, blob.digest, blob.payload)
|
||||
input.stagedBlobDigests.add(blob.digest)
|
||||
}
|
||||
return stagedBytes
|
||||
}
|
||||
|
||||
function assertStagingCapacity(
|
||||
input: {
|
||||
journalDir: string
|
||||
identity: AgentSessionJournalIdentity
|
||||
budget: JournalAppendBudget
|
||||
},
|
||||
additionalBytes: number
|
||||
): Promise<number> {
|
||||
return assertJournalPhysicalCapacity({
|
||||
journalDir: input.journalDir,
|
||||
sessionId: input.identity.sessionId,
|
||||
maxBytes: input.budget.maxSessionBytes,
|
||||
peakAdditionalBytes: additionalBytes
|
||||
})
|
||||
}
|
||||
|
||||
async function publishPreparedFile(
|
||||
stagingDir: string,
|
||||
journalDir: string,
|
||||
fileName: string,
|
||||
sessionId: string,
|
||||
maxBytes: number
|
||||
): Promise<void> {
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
sessionId,
|
||||
maxBytes,
|
||||
peakAdditionalBytes: (await stat(join(stagingDir, fileName))).size
|
||||
})
|
||||
const copied = await copyFileDurable(join(stagingDir, fileName), join(journalDir, fileName))
|
||||
if (!copied) {
|
||||
throw new Error(`prepared journal file disappeared before publish: ${fileName}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPreparedBlob(
|
||||
stagingDir: string,
|
||||
journalDir: string,
|
||||
digest: string,
|
||||
sessionId: string,
|
||||
maxBytes: number
|
||||
): Promise<boolean> {
|
||||
if ((await journalBlobFileSize(journalDir, digest)) !== null) {
|
||||
return false
|
||||
}
|
||||
const size = await journalBlobFileSize(stagingDir, digest)
|
||||
if (size === null) {
|
||||
throw new Error(`prepared journal blob disappeared before publish: ${digest}`)
|
||||
}
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
sessionId,
|
||||
maxBytes,
|
||||
peakAdditionalBytes: size
|
||||
})
|
||||
await mkdir(join(journalDir, JOURNAL_BLOB_DIR), { recursive: true })
|
||||
const copied = await copyFileDurable(
|
||||
join(stagingDir, JOURNAL_BLOB_DIR, digest),
|
||||
join(journalDir, JOURNAL_BLOB_DIR, digest)
|
||||
)
|
||||
if (!copied) {
|
||||
throw new Error(`prepared journal blob disappeared before publish: ${digest}`)
|
||||
}
|
||||
return true
|
||||
// COMMIT landed: on disk the superseded rows are gone and this epoch is the
|
||||
// live one. The caller adopts that immediately, or a later failure leaves the
|
||||
// live store writing into an epoch whose rows were just deleted.
|
||||
state.oldestSequence = 1
|
||||
input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
// Opening a new epoch.
|
||||
//
|
||||
// The snapshot is what names the live epoch, so it is published BEFORE the log
|
||||
// is reset. A crash mid-rollover therefore leaves stale-epoch rows behind the
|
||||
// new snapshot, which `loadJournal` drops — the reverse order would leave a
|
||||
// journal whose log no longer matches any epoch anyone can name.
|
||||
// One transaction: discard every row of the superseded epoch, insert the new
|
||||
// epoch row at sequence 1, move the session projection onto it, and retire any
|
||||
// repair marker the superseded epoch was carrying. Superseded rows are DELETED
|
||||
// rather than retained — nothing would ever shed them.
|
||||
|
||||
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types'
|
||||
import { compactJournal } from './journal-compaction'
|
||||
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
|
||||
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
|
||||
import { journalRowByteLength } from './journal-row-schema'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { clearJournalRepairMarker } from './journal-repair-marker'
|
||||
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
|
||||
import {
|
||||
deleteAllJournalRows,
|
||||
insertJournalRow,
|
||||
upsertJournalSessionRow
|
||||
} from './journal-row-table'
|
||||
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
|
||||
|
||||
export async function publishNewEpoch(input: {
|
||||
journalDir: string
|
||||
export function publishNewEpoch(input: {
|
||||
db: Database.Database
|
||||
sessionId: string
|
||||
providerHandle: AgentSessionProviderHandle
|
||||
epoch: string
|
||||
reason: AgentJournalEpochReason
|
||||
fence: number
|
||||
now: number
|
||||
maxSessionBytes?: number
|
||||
}): Promise<JournalLoad> {
|
||||
/** Called the instant the transaction commits, before any fallible follow-up. */
|
||||
onPublished: (loaded: JournalLoad) => void
|
||||
}): void {
|
||||
const row: JournalRow = {
|
||||
kind: 'epoch',
|
||||
reason: input.reason,
|
||||
@@ -34,25 +39,24 @@ export async function publishNewEpoch(input: {
|
||||
fence: input.fence,
|
||||
ts: input.now
|
||||
}
|
||||
|
||||
input.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
deleteAllJournalRows(input.db)
|
||||
clearJournalRepairMarker(input.db, input.sessionId)
|
||||
insertJournalRow(input.db, input.sessionId, row)
|
||||
upsertJournalSessionRow(input.db, input.sessionId, input.epoch, input.now)
|
||||
input.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
input.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
|
||||
// COMMIT landed: on disk the superseded prefix is gone and this epoch is the
|
||||
// live one. The caller adopts that immediately, or a later failure leaves the
|
||||
// store writing into an epoch that no longer exists.
|
||||
const state = createJournalReducerState(input.sessionId, input.epoch)
|
||||
await compactJournal({
|
||||
journalDir: input.journalDir,
|
||||
state,
|
||||
tailRows: [row],
|
||||
policy: { minTailRows: 1, retainTailMs: Number.POSITIVE_INFINITY },
|
||||
now: input.now,
|
||||
maxSessionBytes: input.maxSessionBytes ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
|
||||
sessionId: input.sessionId
|
||||
})
|
||||
applyJournalRow(state, row)
|
||||
state.oldestSequence = 1
|
||||
return {
|
||||
state,
|
||||
tailRows: [row],
|
||||
compactedThrough: 0,
|
||||
readOnly: false,
|
||||
corrupt: false,
|
||||
malformedRows: 0,
|
||||
sizeBytes: journalRowByteLength(row)
|
||||
}
|
||||
input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Every path that can open a SQLite connection releases it.
|
||||
//
|
||||
// Asserting that the happy path closes cleanly proves nothing: these sites are
|
||||
// reached only when something has already gone wrong. On POSIX a leak is
|
||||
// SILENT — the unlink succeeds — so each case asserts BOTH that the sidecars
|
||||
// are gone and that the directory renames and removes, which is the half that
|
||||
// actually fails on Windows.
|
||||
|
||||
import { access, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { openJournalDatabase } from './journal-database'
|
||||
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
|
||||
import { loadJournal } from './journal-open'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
|
||||
let base: string
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function runningTool(): AgentJournalItemBody {
|
||||
return { kind: 'tool-call', name: 'command', input: {}, state: 'running' }
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
return access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
/** The platform-independent proof: an open handle blocks both of these on
|
||||
* Windows, where every leak in this file actually shows up. */
|
||||
async function expectNothingHoldsTheDirectory(): Promise<void> {
|
||||
const dbPath = journalDatabaseFile(root)
|
||||
expect(await exists(`${dbPath}-wal`)).toBe(false)
|
||||
expect(await exists(`${dbPath}-shm`)).toBe(false)
|
||||
const moved = `${root}-recovered-vtest`
|
||||
await rename(root, moved)
|
||||
await rm(moved, { recursive: true })
|
||||
root = join(base, `journal-${Math.random().toString(36).slice(2)}`)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), 'orca-journal-handles-'))
|
||||
root = join(base, 'journal')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('the standalone probe owns its own connection', () => {
|
||||
it('leaves no handle behind after fifty repeated loads', async () => {
|
||||
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
|
||||
await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 })
|
||||
await journal.close()
|
||||
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const loaded = await loadJournal(root, IDENTITY.sessionId)
|
||||
expect(loaded?.readOnly).toBe(false)
|
||||
}
|
||||
await expectNothingHoldsTheDirectory()
|
||||
})
|
||||
|
||||
it('leaves no handle behind after fifty loads of a latched future schema', async () => {
|
||||
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
|
||||
await journal.close()
|
||||
const seeded = openJournalDatabase(journalDatabaseFile(root))
|
||||
seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 3}`)
|
||||
seeded.db.close()
|
||||
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
// The latched open closes the probe connection and returns the read-only
|
||||
// reopen, so probing repeatedly leaves nothing on a file we must not touch.
|
||||
expect((await loadJournal(root, IDENTITY.sessionId))?.readOnly).toBe(true)
|
||||
}
|
||||
// A read-only connection cannot remove the sidecars it materialized, so only
|
||||
// the rename/remove half is expected to hold here.
|
||||
const moved = `${root}-recovered-vtest`
|
||||
await rename(root, moved)
|
||||
await rm(moved, { recursive: true })
|
||||
root = join(base, 'journal-after-latched')
|
||||
})
|
||||
|
||||
it('returns null for a session with no journal, without creating one', async () => {
|
||||
expect(await loadJournal(root, IDENTITY.sessionId)).toBeNull()
|
||||
expect(await exists(journalDatabaseFile(root))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure paths inside the open call', () => {
|
||||
// Site 1: the raw connection is owned by `openJournalDatabase` until it returns.
|
||||
it('closes the raw connection when the version read cannot run', async () => {
|
||||
await journals.open({ identity: IDENTITY, journalDir: root }).then((journal) => journal.close())
|
||||
await writeFile(journalDatabaseFile(root), 'this is not a database', 'utf8')
|
||||
|
||||
expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow()
|
||||
await expectNothingHoldsTheDirectory()
|
||||
})
|
||||
|
||||
it('closes the raw connection when the migration cannot start', async () => {
|
||||
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
|
||||
await journal.close()
|
||||
const blocker = openJournalDatabase(journalDatabaseFile(root))
|
||||
// Roll the stored version back so the migration runs, then hold the write
|
||||
// lock it needs: the throw lands after the connection already exists.
|
||||
blocker.db.pragma('user_version = 0')
|
||||
blocker.db.exec('BEGIN IMMEDIATE')
|
||||
blocker.db.exec("INSERT INTO journal_sessions VALUES ('other', 'e', 1)")
|
||||
try {
|
||||
expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow()
|
||||
} finally {
|
||||
blocker.db.exec('ROLLBACK')
|
||||
blocker.db.close()
|
||||
}
|
||||
await expectNothingHoldsTheDirectory()
|
||||
}, 60_000)
|
||||
|
||||
// Sites 2 and 3: `open()` closes its own connection on any throw after the
|
||||
// connection exists, which is what lets the factory need no `finally`.
|
||||
it('leaves nothing open when a post-connection step of open() throws', async () => {
|
||||
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
|
||||
await journal.appendItem(item(1), runningTool(), { fence: 1 })
|
||||
await journal.close()
|
||||
|
||||
// Replay runs after the connection is open, so a read it cannot serve
|
||||
// throws with the handle already held.
|
||||
const exec = vi.spyOn(Database.prototype, 'prepare').mockImplementation(() => {
|
||||
throw new Error('replay cannot read this journal')
|
||||
})
|
||||
try {
|
||||
await expect(journals.open({ identity: IDENTITY, journalDir: root })).rejects.toThrow(
|
||||
'replay cannot read this journal'
|
||||
)
|
||||
} finally {
|
||||
exec.mockRestore()
|
||||
}
|
||||
await expectNothingHoldsTheDirectory()
|
||||
})
|
||||
})
|
||||
@@ -5,23 +5,16 @@ import type {
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { journalItemRowBuilder } from './journal-row-builders'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import type { JournalAppendResult } from './journal-store-contracts'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { appendToolOutputFallback } from './journal-tool-output-fallback'
|
||||
|
||||
type ItemAppendOptions = { fence: number; observedAt?: number; recovered?: true }
|
||||
type JournalBlob = { digest: string; payload: string }
|
||||
|
||||
export class JournalItemAppender {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
journal: () => AgentSessionJournal
|
||||
state: () => JournalReducerState
|
||||
enqueue: (
|
||||
build: (seq: number, ts: number) => JournalRow,
|
||||
blobs?: readonly JournalBlob[]
|
||||
) => Promise<JournalRow>
|
||||
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
|
||||
}
|
||||
) {}
|
||||
|
||||
@@ -33,37 +26,10 @@ export class JournalItemAppender {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
return this.deps
|
||||
.enqueue(journalItemRowBuilder(this.deps.state, identity, body, options))
|
||||
.then((row) => itemAppendResult(row, itemId))
|
||||
}
|
||||
|
||||
appendWithBlobs(
|
||||
identity: AgentJournalItemIdentity,
|
||||
body: AgentJournalItemBody,
|
||||
blobs: readonly JournalBlob[],
|
||||
options: ItemAppendOptions
|
||||
): Promise<JournalAppendResult> {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
return this.deps
|
||||
.enqueue(journalItemRowBuilder(this.deps.state, identity, body, options), blobs)
|
||||
.then((row) => itemAppendResult(row, itemId))
|
||||
.catch((error: unknown) =>
|
||||
appendToolOutputFallback({
|
||||
journal: this.deps.journal(),
|
||||
error,
|
||||
identity,
|
||||
body,
|
||||
blobs,
|
||||
itemId,
|
||||
fence: options.fence
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function itemAppendResult(row: JournalRow, itemId: string): JournalAppendResult {
|
||||
return {
|
||||
cursor: { epoch: row.epoch, sequence: row.seq },
|
||||
itemId,
|
||||
revision: (row as Extract<JournalRow, { kind: 'item' }>).revision
|
||||
.then((row) => ({
|
||||
cursor: { epoch: row.epoch, sequence: row.seq },
|
||||
itemId,
|
||||
revision: (row as Extract<JournalRow, { kind: 'item' }>).revision
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
// results by identity read off the same raw lines. Fixtures are shaped like the
|
||||
// files the providers actually write.
|
||||
|
||||
import { mkdtemp, readdir, 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, 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 { JOURNAL_BLOB_DIR, readJournalBlob } from './journal-blob-store'
|
||||
import type {
|
||||
AgentSessionJournalIdentity,
|
||||
AgentSessionProviderHandle
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { createLegacyIdentityTracker } from './journal-legacy-identity'
|
||||
import {
|
||||
appendLegacyTranscriptMessages,
|
||||
importLegacyTranscriptIntoJournal
|
||||
} from './journal-legacy-import'
|
||||
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
|
||||
@@ -29,21 +31,29 @@ function tick(): number {
|
||||
return clock
|
||||
}
|
||||
|
||||
function identity(agent: 'claude' | 'codex', sessionId: string): AgentSessionJournalIdentity {
|
||||
type ImportAgent = 'claude' | 'codex' | 'grok' | 'omp'
|
||||
|
||||
function providerHandle(agent: ImportAgent, sessionId: string): AgentSessionProviderHandle {
|
||||
if (agent === 'claude') {
|
||||
return { kind: 'claude', sessionId, leafUuid: null }
|
||||
}
|
||||
return agent === 'codex'
|
||||
? { kind: 'codex', threadId: sessionId }
|
||||
: { kind: 'opaque', agent, value: sessionId }
|
||||
}
|
||||
|
||||
function identity(agent: ImportAgent, sessionId: string): AgentSessionJournalIdentity {
|
||||
return {
|
||||
sessionId,
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent,
|
||||
providerHandle:
|
||||
agent === 'claude'
|
||||
? { kind: 'claude', sessionId, leafUuid: null }
|
||||
: { kind: 'codex', threadId: sessionId }
|
||||
providerHandle: providerHandle(agent, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async function open(
|
||||
agent: 'claude' | 'codex',
|
||||
agent: ImportAgent,
|
||||
sessionId: string,
|
||||
overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}
|
||||
): Promise<AgentSessionJournal> {
|
||||
@@ -387,7 +397,7 @@ describe('codex import', () => {
|
||||
})
|
||||
|
||||
describe('payload bounds on import', () => {
|
||||
it('marks a clipped tool result and parks the remainder in the blob store', async () => {
|
||||
it('marks a clipped tool result and discards the remainder', async () => {
|
||||
const output = 'y'.repeat(64 * 1024)
|
||||
const filePath = await writeFixture('claude-big.jsonl', [
|
||||
{
|
||||
@@ -421,167 +431,13 @@ describe('payload bounds on import', () => {
|
||||
expect(body.output.truncated).toBe(true)
|
||||
expect(body.output.byteLength).toBe(64 * 1024)
|
||||
expect(body.output.head).toHaveLength(1_024)
|
||||
expect(await readJournalBlob(root, body.output.digest)).toBe(output)
|
||||
})
|
||||
|
||||
it('deduplicates staged blobs while importing a replacement epoch', async () => {
|
||||
const journalDir = join(root, 'dedupe-journal')
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 512,
|
||||
maxSessionBytes: 512 * 1024
|
||||
}
|
||||
const output = 'd'.repeat(32 * 1024)
|
||||
const bounded = boundPayload(output, limits)
|
||||
const toolResultLine = (uuid: string) => ({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: `toolu_${uuid}`, content: output }]
|
||||
},
|
||||
uuid,
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
sessionId: CLAUDE_SESSION
|
||||
})
|
||||
const filePath = await writeFixture('claude-duplicate-blobs.jsonl', [
|
||||
toolResultLine('aa11bb22-cc33-4d44-8e55-6f7788990011'),
|
||||
toolResultLine('bb22cc33-dd44-4e55-8f66-778899001122')
|
||||
])
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
|
||||
await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
|
||||
expect(await readJournalBlob(journalDir, bounded.digest)).toBe(output)
|
||||
expect(await readdir(join(journalDir, JOURNAL_BLOB_DIR))).toEqual([bounded.digest])
|
||||
expect(
|
||||
journal
|
||||
.snapshot()
|
||||
.items.map((item) => (item.body.kind === 'tool-call' ? item.body.output?.digest : null))
|
||||
).toEqual([bounded.digest, bounded.digest])
|
||||
})
|
||||
|
||||
it('prunes root-level blobs made stale by a later legacy import', async () => {
|
||||
const journalDir = join(root, 'prune-journal')
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 512,
|
||||
maxSessionBytes: 512 * 1024
|
||||
}
|
||||
const output = 's'.repeat(32 * 1024)
|
||||
const bounded = boundPayload(output, limits)
|
||||
const first = await writeFixture('claude-stale-blob.jsonl', [
|
||||
{
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_stale', content: output }]
|
||||
},
|
||||
uuid: 'aa11bb22-cc33-4d44-8e55-6f7788990011',
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
sessionId: CLAUDE_SESSION
|
||||
}
|
||||
])
|
||||
const second = await writeFixture('claude-without-blob.jsonl', [
|
||||
{
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'replacement' }] },
|
||||
uuid: 'cc33dd44-ee55-4666-8777-889900112233',
|
||||
timestamp: '2026-08-05T10:00:10.000Z',
|
||||
sessionId: CLAUDE_SESSION
|
||||
}
|
||||
])
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
|
||||
await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath: first, limits }
|
||||
})
|
||||
expect(await readJournalBlob(journalDir, bounded.digest)).toBe(output)
|
||||
|
||||
await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 2,
|
||||
options: { filePath: second, limits }
|
||||
})
|
||||
|
||||
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
|
||||
expect(journal.snapshot().items[0]?.body).toMatchObject({
|
||||
kind: 'message',
|
||||
blocks: [{ type: 'text', text: 'replacement' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses managed catch-up appends when a tool-result blob exceeds quota', async () => {
|
||||
const journalDir = join(root, 'catchup-journal')
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 128,
|
||||
maxSessionBytes: 8_000
|
||||
}
|
||||
const journal = await open('codex', CODEX_SESSION, {
|
||||
journalDir,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
const output = 'z'.repeat(12_000)
|
||||
const bounded = boundPayload(output, limits)
|
||||
|
||||
await expect(
|
||||
appendLegacyTranscriptMessages({
|
||||
journal,
|
||||
agent: 'codex',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'catchup-tool-output',
|
||||
role: 'tool',
|
||||
blocks: [{ type: 'tool-result', output }],
|
||||
timestamp: 1_800_000_000_000,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
|
||||
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('import failures', () => {
|
||||
it('rejects a legacy source above the fixed 16 MiB import cap before decoding', async () => {
|
||||
const journalDir = join(root, 'oversized-source-journal')
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir,
|
||||
limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 256 * 1024 * 1024 },
|
||||
autoCompact: false
|
||||
})
|
||||
const journal = await open('claude', CLAUDE_SESSION, { journalDir })
|
||||
const filePath = join(root, 'oversized-source.jsonl')
|
||||
await writeFile(filePath, 'x'.repeat(16 * 1024 * 1024 + 1), 'utf8')
|
||||
const epoch = journal.epoch
|
||||
@@ -602,115 +458,10 @@ describe('import failures', () => {
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the live epoch intact when a staged rebuild runs out of budget', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 }
|
||||
const journal = await open('codex', CODEX_SESSION, { limits })
|
||||
await appendLegacyTranscriptMessages({
|
||||
journal,
|
||||
agent: 'codex',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 'durable-prefix',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'keep me' }],
|
||||
timestamp: 1_800_000_000_000,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
})
|
||||
const filePath = await writeFixture('oversized-rollout.jsonl', [
|
||||
CODEX_LINES[0],
|
||||
CODEX_LINES[1],
|
||||
CODEX_LINES[2],
|
||||
{
|
||||
type: 'event_msg',
|
||||
timestamp: '2026-08-05T10:00:03.000Z',
|
||||
payload: { type: 'agent_message', message: 'x'.repeat(2_000) }
|
||||
}
|
||||
])
|
||||
const epoch = journal.epoch
|
||||
const snapshotPath = join(root, 'snapshot.json')
|
||||
const logPath = join(root, 'log.jsonl')
|
||||
const before = {
|
||||
snapshot: await readFile(snapshotPath, 'utf-8'),
|
||||
log: await readFile(logPath, 'utf-8')
|
||||
}
|
||||
|
||||
await expect(
|
||||
importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'codex',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
expect(journal.epoch).toBe(epoch)
|
||||
expect(await readFile(snapshotPath, 'utf-8')).toBe(before.snapshot)
|
||||
expect(await readFile(logPath, 'utf-8')).toBe(before.log)
|
||||
expect(journal.snapshot().items[0]?.body).toMatchObject({
|
||||
kind: 'message',
|
||||
blocks: [{ type: 'text', text: 'keep me' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans staged replacement blobs when legacy import exceeds physical quota', async () => {
|
||||
const journalDir = join(root, 'replacement-journal')
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 128,
|
||||
maxSessionBytes: 8_000
|
||||
}
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
const output = 'q'.repeat(12_000)
|
||||
const bounded = boundPayload(output, limits)
|
||||
const filePath = await writeFixture('oversized-tool-result.jsonl', [
|
||||
{
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'toolu_oversized', content: output }]
|
||||
},
|
||||
uuid: 'ba11ad00-1111-4222-8333-444455556666',
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
sessionId: CLAUDE_SESSION
|
||||
}
|
||||
])
|
||||
const epoch = journal.epoch
|
||||
|
||||
await expect(
|
||||
importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
|
||||
expect(journal.epoch).toBe(epoch)
|
||||
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
|
||||
expect((await readdir(journalDir)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds oversized legacy tool-call input before journal publication', async () => {
|
||||
const journalDir = join(root, 'bounded-tool-input-journal')
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 }
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
const journal = await open('claude', CLAUDE_SESSION, { journalDir })
|
||||
const filePath = await writeFixture('oversized-tool-input.jsonl', [
|
||||
{
|
||||
parentUuid: null,
|
||||
@@ -768,6 +519,39 @@ describe('import failures', () => {
|
||||
expect(journal.epoch).toBe(before)
|
||||
})
|
||||
|
||||
// A transcript with no decodable messages recovers nothing. Publishing an
|
||||
// empty replacement would roll the epoch and drop whatever the journal held —
|
||||
// including a repair's own anchor and disclosure.
|
||||
it('leaves the epoch untouched when the transcript decodes to no messages', async () => {
|
||||
const journal = await open('codex', CODEX_SESSION)
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 },
|
||||
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] },
|
||||
{ fence: 1 }
|
||||
)
|
||||
const before = journal.epoch
|
||||
const metadataOnly = await writeFixture('metadata-only.jsonl', [
|
||||
{
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-08-05T10:00:00.000Z',
|
||||
payload: { id: CODEX_SESSION, session_id: CODEX_SESSION, cwd: '/Users/dev/project' }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'codex',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath: metadataOnly }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true, imported: 0, replaced: false })
|
||||
expect(journal.epoch).toBe(before)
|
||||
expect(journal.snapshot().items).toHaveLength(1)
|
||||
await journal.close()
|
||||
})
|
||||
|
||||
it('rejects an agent with no transcript decoder', async () => {
|
||||
const journal = await open('claude', CLAUDE_SESSION)
|
||||
const result = await importLegacyTranscriptIntoJournal({
|
||||
@@ -780,3 +564,132 @@ describe('import failures', () => {
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
})
|
||||
})
|
||||
|
||||
// A tool call is only the SOLE block of its message when the provider wrote it
|
||||
// that way. Claude interleaves it with narration, Grok hangs `tool_calls` off a
|
||||
// row that also has text, and omp's execution cells always pair the invocation
|
||||
// with its output — so the multi-block path carries untrusted tool input too.
|
||||
describe('multi-block legacy messages', () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 }
|
||||
const oversized = 'x'.repeat(10_000)
|
||||
|
||||
/** The tool-call block of the first imported multi-block message. */
|
||||
function importedToolCallBlock(journal: AgentSessionJournal): unknown {
|
||||
for (const entry of journal.snapshot().items) {
|
||||
if (entry.body.kind !== 'message') {
|
||||
continue
|
||||
}
|
||||
const block = entry.body.blocks.find((candidate) => candidate.type === 'tool-call')
|
||||
if (block) {
|
||||
return block.input
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
it('bounds a Claude tool call that shares its message with narration', async () => {
|
||||
const journal = await open('claude', CLAUDE_SESSION, {
|
||||
journalDir: join(root, 'claude-mixed-journal')
|
||||
})
|
||||
const filePath = await writeFixture('claude-mixed.jsonl', [
|
||||
{
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Editing the file.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_mixed',
|
||||
name: 'Edit',
|
||||
input: { file_path: 'a.ts', patch: oversized }
|
||||
}
|
||||
]
|
||||
},
|
||||
uuid: 'dd22be00-1111-4222-8333-444455556666',
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
sessionId: CLAUDE_SESSION
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'claude',
|
||||
sessionId: CLAUDE_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(importedToolCallBlock(journal)).toMatchObject({
|
||||
truncated: true,
|
||||
byteLength: expect.any(Number),
|
||||
digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
head: expect.any(String)
|
||||
})
|
||||
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
|
||||
await journal.close()
|
||||
})
|
||||
|
||||
it('bounds a Grok tool call that shares its row with assistant text', async () => {
|
||||
const journal = await open('grok', CODEX_SESSION, {
|
||||
journalDir: join(root, 'grok-mixed-journal')
|
||||
})
|
||||
const filePath = await writeFixture('grok-mixed.jsonl', [
|
||||
{
|
||||
type: 'assistant',
|
||||
id: 'asst-mixed',
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
content: [{ type: 'text', text: 'Searching.' }],
|
||||
tool_calls: [{ id: 'c1', name: 'grep', arguments: JSON.stringify({ pattern: oversized }) }]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'grok',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(importedToolCallBlock(journal)).toMatchObject({ truncated: true })
|
||||
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
|
||||
await journal.close()
|
||||
})
|
||||
|
||||
it('bounds an omp execution cell, whose invocation always ships with its output', async () => {
|
||||
const journal = await open('omp', CODEX_SESSION, {
|
||||
journalDir: join(root, 'omp-mixed-journal')
|
||||
})
|
||||
const filePath = await writeFixture('omp-mixed.jsonl', [
|
||||
{
|
||||
type: 'message',
|
||||
id: 'omp-mixed-1',
|
||||
timestamp: '2026-08-05T10:00:09.000Z',
|
||||
message: {
|
||||
role: 'bashExecution',
|
||||
command: `echo ${oversized}`,
|
||||
output: 'done',
|
||||
exitCode: 0
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importLegacyTranscriptIntoJournal({
|
||||
journal,
|
||||
agent: 'omp',
|
||||
sessionId: CODEX_SESSION,
|
||||
fence: 1,
|
||||
options: { filePath, limits }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(importedToolCallBlock(journal)).toMatchObject({ truncated: true })
|
||||
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
|
||||
await journal.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,7 +49,14 @@ export type LegacyImportOptions = ResolveSessionFileOptions & {
|
||||
const MAX_LEGACY_IMPORT_SOURCE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
export type LegacyImportResult =
|
||||
| { ok: true; epoch: string; cursor: AgentJournalCursor; imported: number }
|
||||
| {
|
||||
ok: true
|
||||
epoch: string
|
||||
cursor: AgentJournalCursor
|
||||
imported: number
|
||||
/** False when the transcript held no messages and the epoch was left as it stood. */
|
||||
replaced: boolean
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
export async function appendLegacyTranscriptMessages(input: {
|
||||
@@ -61,16 +68,14 @@ export async function appendLegacyTranscriptMessages(input: {
|
||||
}): Promise<number> {
|
||||
let appended = 0
|
||||
for (const message of input.messages) {
|
||||
const mapped = legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
|
||||
await input.journal.appendItemWithBlobs(
|
||||
await input.journal.appendItem(
|
||||
{
|
||||
provider: 'legacy',
|
||||
agent: input.agent,
|
||||
sessionId: input.sessionId,
|
||||
recordId: message.id
|
||||
},
|
||||
mapped.body,
|
||||
mapped.blobs,
|
||||
legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
{ fence: input.fence, observedAt: message.timestamp ?? undefined }
|
||||
)
|
||||
appended += 1
|
||||
@@ -134,16 +139,28 @@ export async function importLegacyTranscriptIntoJournal(input: {
|
||||
if (!identity) {
|
||||
continue
|
||||
}
|
||||
const mapped = legacyItemBody(message, limits)
|
||||
replacement.push({
|
||||
identity,
|
||||
body: mapped.body,
|
||||
blobs: mapped.blobs,
|
||||
body: legacyItemBody(message, limits),
|
||||
observedAt: message.timestamp ?? undefined
|
||||
})
|
||||
}
|
||||
// A transcript that decodes to nothing reconstructs nothing, and an empty
|
||||
// replacement is not a harmless no-op: it would delete the repair's anchor and
|
||||
// its disclosure, leaving nothing to ask for the history again. The epoch
|
||||
// stands so a later read can still rebuild it.
|
||||
if (replacement.length === 0) {
|
||||
const current = input.journal.cursor()
|
||||
return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false }
|
||||
}
|
||||
const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement)
|
||||
return { ok: true, epoch: cursor.epoch, cursor, imported: decoded.messages.length }
|
||||
return {
|
||||
ok: true,
|
||||
epoch: cursor.epoch,
|
||||
cursor,
|
||||
imported: decoded.messages.length,
|
||||
replaced: true
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSCRIPT_DECODERS = {
|
||||
@@ -199,11 +216,6 @@ async function decodeWithIdentities(input: {
|
||||
return { messages, identities }
|
||||
}
|
||||
|
||||
type MappedLegacyItem = {
|
||||
body: AgentJournalItemBody
|
||||
blobs: { digest: string; payload: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A message whose only content is a tool invocation becomes a tool-call item so
|
||||
* the reducer renders it as one. Everything else stays a message item with its
|
||||
@@ -212,49 +224,40 @@ type MappedLegacyItem = {
|
||||
function legacyItemBody(
|
||||
message: NativeChatMessage,
|
||||
limits: JournalPayloadLimits
|
||||
): MappedLegacyItem {
|
||||
): AgentJournalItemBody {
|
||||
const only = message.blocks.length === 1 ? message.blocks[0] : undefined
|
||||
if (only?.type === 'tool-call') {
|
||||
// Legacy transcripts are untrusted and can contain arbitrarily large tool
|
||||
// arguments. Keep them on the same bounded path as live events before the
|
||||
// replacement epoch is published.
|
||||
return {
|
||||
// Legacy transcripts are untrusted and can contain arbitrarily large
|
||||
// tool arguments. Keep them on the same bounded path as live events
|
||||
// before the replacement epoch is staged or published.
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: only.name,
|
||||
input: boundToolInput(only.input, limits),
|
||||
state: 'completed'
|
||||
},
|
||||
blobs: []
|
||||
kind: 'tool-call',
|
||||
name: only.name,
|
||||
input: boundToolInput(only.input, limits),
|
||||
state: 'completed'
|
||||
}
|
||||
}
|
||||
if (only?.type === 'tool-result') {
|
||||
const output = boundPayload(only.output, limits)
|
||||
return {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'tool-result',
|
||||
input: null,
|
||||
state: only.isError ? 'failed' : 'completed',
|
||||
output
|
||||
},
|
||||
blobs: output.truncated ? [{ digest: output.digest, payload: only.output }] : []
|
||||
kind: 'tool-call',
|
||||
name: 'tool-result',
|
||||
input: null,
|
||||
state: only.isError ? 'failed' : 'completed',
|
||||
output: boundPayload(only.output, limits)
|
||||
}
|
||||
}
|
||||
return {
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: message.role,
|
||||
blocks: message.blocks.map((block) => boundBlock(block, limits))
|
||||
},
|
||||
blobs: []
|
||||
kind: 'message',
|
||||
role: message.role,
|
||||
blocks: message.blocks.map((block) => boundBlock(block, limits))
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline block text keeps only a bounded head plus an explicit marker. No blob
|
||||
* is written: the marker carries the digest and byte length, and the source
|
||||
* transcript remains the full copy — a blob here would be unreferenced by the
|
||||
* render model and pruned at the next compaction. */
|
||||
/** Every block that can carry untrusted bulk is bounded here, tool calls
|
||||
* included: a provider decoder is free to put one alongside narration, and the
|
||||
* sole-block path above never sees those. The remainder is discarded rather
|
||||
* than stored elsewhere — the marker keeps its digest and byte length, and the
|
||||
* source transcript remains the full copy. */
|
||||
function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock {
|
||||
if (block.type === 'text') {
|
||||
return { ...block, text: boundInlineText(block.text, limits).text }
|
||||
@@ -262,5 +265,8 @@ function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): Nativ
|
||||
if (block.type === 'tool-result') {
|
||||
return { ...block, output: boundInlineText(block.output, limits).text }
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
return { ...block, input: boundToolInput(block.input, limits) }
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalSnapshot
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
dispatchReservationId,
|
||||
JournalLifecycleCapacity,
|
||||
lifecycleReservationIdForItem,
|
||||
requiresTerminalSettlement,
|
||||
terminalReservationBytes,
|
||||
type JournalLifecycleReservation
|
||||
} from './journal-lifecycle-capacity'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { journalRowByteLength } from './journal-row-schema'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
|
||||
export type JournalLifecycleRowAdmission = {
|
||||
releaseAfter: string[]
|
||||
protectedBytes: number
|
||||
lifecycleCovered: boolean
|
||||
proposedCapacity: JournalLifecycleCapacity
|
||||
}
|
||||
|
||||
export class JournalLifecycleAdmission {
|
||||
private readonly capacity = new JournalLifecycleCapacity()
|
||||
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly maxBytes: number,
|
||||
private readonly canonicalItemId: (itemId: string) => string,
|
||||
private readonly maxAppendSlots = Number.MAX_SAFE_INTEGER
|
||||
) {}
|
||||
|
||||
get state(): { reservedBytes: number; reservedAppendSlots: number } {
|
||||
return {
|
||||
reservedBytes: this.capacity.reservedBytes,
|
||||
reservedAppendSlots: this.capacity.reservedAppendSlots
|
||||
}
|
||||
}
|
||||
|
||||
rebuild(snapshot: AgentJournalSnapshot, currentPhysicalBytes: number): void {
|
||||
if (
|
||||
!this.capacity.rebuild(snapshot, this.maxBytes, currentPhysicalBytes, this.maxAppendSlots)
|
||||
) {
|
||||
throw this.capacityError('cannot rebuild lifecycle capacity')
|
||||
}
|
||||
}
|
||||
|
||||
reserve(token: JournalLifecycleReservation, currentPhysicalBytes: number): boolean {
|
||||
return this.capacity.reserve(token, currentPhysicalBytes, this.maxBytes, this.maxAppendSlots)
|
||||
}
|
||||
|
||||
transfer(fromId: string, toId: string): boolean {
|
||||
return this.capacity.transfer(fromId, toId)
|
||||
}
|
||||
|
||||
release(id: string): void {
|
||||
this.capacity.release(id)
|
||||
}
|
||||
|
||||
prepare(row: JournalRow, currentPhysicalBytes: number): JournalLifecycleRowAdmission {
|
||||
const proposedCapacity = this.capacity.clone()
|
||||
this.ensureActionable(row, currentPhysicalBytes, proposedCapacity)
|
||||
const releaseAfter = this.reservationsSettledBy(row, proposedCapacity)
|
||||
const releasedBytes = releaseAfter.reduce(
|
||||
(total, id) => total + (proposedCapacity.token(id)?.bytes ?? 0),
|
||||
0
|
||||
)
|
||||
return {
|
||||
releaseAfter,
|
||||
protectedBytes: proposedCapacity.reservedBytes - releasedBytes,
|
||||
lifecycleCovered: proposedCapacity.covers(releaseAfter, journalRowByteLength(row), 1),
|
||||
proposedCapacity
|
||||
}
|
||||
}
|
||||
|
||||
commit(admission: JournalLifecycleRowAdmission): void {
|
||||
this.capacity.replaceFrom(admission.proposedCapacity)
|
||||
for (const id of admission.releaseAfter) {
|
||||
this.capacity.release(id)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureActionable(
|
||||
row: JournalRow,
|
||||
currentPhysicalBytes: number,
|
||||
capacity: JournalLifecycleCapacity
|
||||
): void {
|
||||
if (row.kind === 'item') {
|
||||
this.ensureActionableItem(row.itemId, row.body, currentPhysicalBytes, capacity)
|
||||
return
|
||||
}
|
||||
if (row.kind !== 'lifecycle-batch') {
|
||||
return
|
||||
}
|
||||
for (const mutation of row.mutations) {
|
||||
if (mutation.kind === 'item') {
|
||||
this.ensureActionableItem(mutation.itemId, mutation.body, currentPhysicalBytes, capacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureActionableItem(
|
||||
itemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
currentPhysicalBytes: number,
|
||||
capacity: JournalLifecycleCapacity
|
||||
): void {
|
||||
if (!requiresTerminalSettlement(body)) {
|
||||
return
|
||||
}
|
||||
const id = lifecycleReservationIdForItem(this.canonicalItemId(itemId))
|
||||
if (body.kind === 'status' && body.turnLifecycle?.state === 'running' && !capacity.has(id)) {
|
||||
capacity.claimFirst('tentative-turn:', id)
|
||||
}
|
||||
if (
|
||||
!capacity.reserve(
|
||||
{ id, bytes: terminalReservationBytes(body), appendSlots: 1 },
|
||||
currentPhysicalBytes,
|
||||
this.maxBytes,
|
||||
this.maxAppendSlots
|
||||
)
|
||||
) {
|
||||
throw this.capacityError('cannot reserve terminal capacity')
|
||||
}
|
||||
}
|
||||
|
||||
private reservationsSettledBy(row: JournalRow, capacity: JournalLifecycleCapacity): string[] {
|
||||
if (row.kind === 'dispatch') {
|
||||
const id = dispatchReservationId(row.clientMessageId)
|
||||
return capacity.has(id) ? [id] : []
|
||||
}
|
||||
const itemIds =
|
||||
row.kind === 'item'
|
||||
? requiresTerminalSettlement(row.body)
|
||||
? []
|
||||
: [row.itemId]
|
||||
: row.kind === 'tombstone'
|
||||
? [row.itemId]
|
||||
: row.kind === 'lifecycle-batch'
|
||||
? row.mutations.flatMap((mutation) =>
|
||||
mutation.kind === 'item' && requiresTerminalSettlement(mutation.body)
|
||||
? []
|
||||
: [mutation.itemId]
|
||||
)
|
||||
: []
|
||||
return [
|
||||
...new Set(
|
||||
itemIds.map((itemId) => lifecycleReservationIdForItem(this.canonicalItemId(itemId)))
|
||||
)
|
||||
].filter((id) => capacity.has(id))
|
||||
}
|
||||
|
||||
private capacityError(detail: string): AgentSessionJournalError {
|
||||
return new AgentSessionJournalError(
|
||||
'journal_bound_exceeded',
|
||||
`agent-session journal for ${this.sessionId} ${detail}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
|
||||
import { JournalLifecycleCapacity } from './journal-lifecycle-capacity'
|
||||
|
||||
describe('JournalLifecycleCapacity', () => {
|
||||
it('enforces append-slot limits for both rebuilt submission reservations', () => {
|
||||
const snapshot: AgentJournalSnapshot = {
|
||||
sessionId: 'session-1',
|
||||
cursor: { epoch: 'epoch-1', sequence: 1 },
|
||||
items: [],
|
||||
submissions: [
|
||||
{
|
||||
clientMessageId: 'message-1',
|
||||
fence: 0,
|
||||
payloadFingerprint: 'fingerprint',
|
||||
dispatchState: 'pending',
|
||||
providerItemId: null,
|
||||
reason: null,
|
||||
submittedAt: 1,
|
||||
resolvedAt: null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const capacity = new JournalLifecycleCapacity()
|
||||
|
||||
expect(capacity.rebuild(snapshot, Number.MAX_SAFE_INTEGER, 0, 1)).toBe(false)
|
||||
expect(capacity.reservedAppendSlots).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalSnapshot
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
|
||||
export type JournalLifecycleReservation = {
|
||||
id: string
|
||||
bytes: number
|
||||
appendSlots: number
|
||||
}
|
||||
|
||||
export const JOURNAL_TURN_TERMINAL_RESERVATION_BYTES = 128 * 1024
|
||||
export const JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES = 64 * 1024
|
||||
export const JOURNAL_DISPATCH_RESERVATION_BYTES = 32 * 1024
|
||||
|
||||
export class JournalLifecycleCapacity {
|
||||
private readonly reservations = new Map<string, JournalLifecycleReservation>()
|
||||
|
||||
get reservedBytes(): number {
|
||||
return [...this.reservations.values()].reduce((total, token) => total + token.bytes, 0)
|
||||
}
|
||||
|
||||
get reservedAppendSlots(): number {
|
||||
return [...this.reservations.values()].reduce((total, token) => total + token.appendSlots, 0)
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.reservations.has(id)
|
||||
}
|
||||
|
||||
token(id: string): JournalLifecycleReservation | null {
|
||||
return this.reservations.get(id) ?? null
|
||||
}
|
||||
|
||||
clone(): JournalLifecycleCapacity {
|
||||
const copy = new JournalLifecycleCapacity()
|
||||
for (const token of this.reservations.values()) {
|
||||
copy.reservations.set(token.id, { ...token })
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
replaceFrom(source: JournalLifecycleCapacity): void {
|
||||
this.reservations.clear()
|
||||
for (const token of source.reservations.values()) {
|
||||
this.reservations.set(token.id, { ...token })
|
||||
}
|
||||
}
|
||||
|
||||
reserve(
|
||||
token: JournalLifecycleReservation,
|
||||
currentPhysicalBytes: number,
|
||||
maxBytes: number,
|
||||
maxAppendSlots = Number.MAX_SAFE_INTEGER
|
||||
): boolean {
|
||||
if (this.reservations.has(token.id)) {
|
||||
return true
|
||||
}
|
||||
if (currentPhysicalBytes + this.reservedBytes + token.bytes > maxBytes) {
|
||||
return false
|
||||
}
|
||||
if (this.reservedAppendSlots + token.appendSlots > maxAppendSlots) {
|
||||
return false
|
||||
}
|
||||
this.reservations.set(token.id, token)
|
||||
return true
|
||||
}
|
||||
|
||||
transfer(fromId: string, toId: string): boolean {
|
||||
const existing = this.reservations.get(fromId)
|
||||
if (!existing) {
|
||||
return false
|
||||
}
|
||||
this.reservations.delete(fromId)
|
||||
this.reservations.set(toId, { ...existing, id: toId })
|
||||
return true
|
||||
}
|
||||
|
||||
claimFirst(prefix: string, toId: string): boolean {
|
||||
const fromId = [...this.reservations.keys()].find((id) => id.startsWith(prefix))
|
||||
return fromId ? this.transfer(fromId, toId) : false
|
||||
}
|
||||
|
||||
release(id: string): void {
|
||||
this.reservations.delete(id)
|
||||
}
|
||||
|
||||
covers(ids: readonly string[], bytes: number, appendSlots: number): boolean {
|
||||
const tokens = ids.flatMap((id) => {
|
||||
const token = this.reservations.get(id)
|
||||
return token ? [token] : []
|
||||
})
|
||||
return (
|
||||
tokens.length > 0 &&
|
||||
tokens.reduce((total, token) => total + token.bytes, 0) >= bytes &&
|
||||
tokens.reduce((total, token) => total + token.appendSlots, 0) >= appendSlots
|
||||
)
|
||||
}
|
||||
|
||||
rebuild(
|
||||
snapshot: AgentJournalSnapshot,
|
||||
maxBytes: number,
|
||||
currentPhysicalBytes: number,
|
||||
maxAppendSlots = Number.MAX_SAFE_INTEGER
|
||||
): boolean {
|
||||
this.reservations.clear()
|
||||
for (const item of snapshot.items) {
|
||||
if (!requiresTerminalSettlement(item.body)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!this.reserve(
|
||||
{
|
||||
id: lifecycleReservationIdForItem(item.itemId),
|
||||
bytes: terminalReservationBytes(item.body),
|
||||
appendSlots: 1
|
||||
},
|
||||
currentPhysicalBytes,
|
||||
maxBytes,
|
||||
maxAppendSlots
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (const submission of snapshot.submissions) {
|
||||
if (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') {
|
||||
continue
|
||||
}
|
||||
// A write-ahead submission owns both its dispatch attempt and the
|
||||
// terminal turn settlement. Rebuild both reservations after restart;
|
||||
// restoring only the tentative turn token would let a new send consume
|
||||
// the dispatch headroom still owed to this unresolved submission.
|
||||
if (
|
||||
!this.reserve(
|
||||
{
|
||||
id: dispatchReservationId(submission.clientMessageId),
|
||||
bytes: JOURNAL_DISPATCH_RESERVATION_BYTES,
|
||||
appendSlots: 1
|
||||
},
|
||||
currentPhysicalBytes,
|
||||
maxBytes,
|
||||
maxAppendSlots
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!this.reserve(
|
||||
{
|
||||
id: tentativeTurnReservationId(submission.clientMessageId),
|
||||
bytes: JOURNAL_TURN_TERMINAL_RESERVATION_BYTES,
|
||||
appendSlots: 1
|
||||
},
|
||||
currentPhysicalBytes,
|
||||
maxBytes,
|
||||
maxAppendSlots
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function lifecycleReservationIdForItem(itemId: string): string {
|
||||
return `item:${itemId}`
|
||||
}
|
||||
|
||||
export function dispatchReservationId(clientMessageId: string): string {
|
||||
return `dispatch:${clientMessageId}`
|
||||
}
|
||||
|
||||
export function tentativeTurnReservationId(clientMessageId: string): string {
|
||||
return `tentative-turn:${clientMessageId}`
|
||||
}
|
||||
|
||||
export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean {
|
||||
if (body.kind === 'tool-call') {
|
||||
return body.state === 'running'
|
||||
}
|
||||
if (body.kind === 'approval' || body.kind === 'question') {
|
||||
return body.resolution.state === 'pending'
|
||||
}
|
||||
return body.kind === 'status' && body.turnLifecycle?.state === 'running'
|
||||
}
|
||||
|
||||
export function terminalReservationBytes(body: AgentJournalItemBody): number {
|
||||
return body.kind === 'status' && body.turnLifecycle?.state === 'running'
|
||||
? JOURNAL_TURN_TERMINAL_RESERVATION_BYTES
|
||||
: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appendJournalRows, JOURNAL_SNAPSHOT_FILE, readJournalSnapshot } from './journal-log-file'
|
||||
import type { JournalSnapshotFile } from './journal-log-file'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
import {
|
||||
projectStructuredAgentSessionStatus,
|
||||
projectStructuredItemsToNativeChat
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
|
||||
type FakeDirectoryHandle = { sync: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }
|
||||
|
||||
let openDirectoryHook: ((path: unknown, flags: unknown) => FakeDirectoryHandle | undefined) | null =
|
||||
null
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
open: (async (...args: Parameters<typeof actual.open>) => {
|
||||
const fake = openDirectoryHook?.(args[0], args[1])
|
||||
return fake ?? actual.open(...args)
|
||||
}) as typeof actual.open
|
||||
}
|
||||
})
|
||||
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-log-file-'))
|
||||
openDirectoryHook = null
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
openDirectoryHook = null
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function validSnapshot(): JournalSnapshotFile {
|
||||
return {
|
||||
v: 1,
|
||||
epoch: 'epoch-A',
|
||||
compactedThrough: 2,
|
||||
highestFence: 1,
|
||||
items: [
|
||||
{
|
||||
itemId: 'codex:thread-1:turn-1:1',
|
||||
revision: 1,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
sequence: 2,
|
||||
observedAt: 1_000
|
||||
}
|
||||
],
|
||||
submissions: [],
|
||||
receipts: [],
|
||||
aliases: [],
|
||||
tombstones: [{ itemId: 'codex:thread-1:turn-1:2', revision: 3 }],
|
||||
tail: []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(snapshot: unknown): Promise<void> {
|
||||
await writeFile(join(root, JOURNAL_SNAPSHOT_FILE), JSON.stringify(snapshot), 'utf-8')
|
||||
}
|
||||
|
||||
describe('readJournalSnapshot validation', () => {
|
||||
it('accepts a well-formed snapshot, with and without the tombstones collection', async () => {
|
||||
await writeSnapshot(validSnapshot())
|
||||
expect((await readJournalSnapshot(root)).status).toBe('valid')
|
||||
|
||||
const { tombstones: _tombstones, ...withoutTombstones } = validSnapshot()
|
||||
await writeSnapshot(withoutTombstones)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('valid')
|
||||
})
|
||||
|
||||
it('accepts every canonical item kind and a fully-formed submission', async () => {
|
||||
const snapshot = validSnapshot()
|
||||
const payload = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true }
|
||||
snapshot.items = [
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
|
||||
{
|
||||
kind: 'tool-call',
|
||||
name: 'Read',
|
||||
input: { path: 'a' },
|
||||
state: 'completed',
|
||||
output: payload
|
||||
},
|
||||
{ kind: 'diff', path: 'a.ts', patch: payload },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run?',
|
||||
detail: null,
|
||||
options: [{ id: 'a', label: 'Yes' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
{
|
||||
kind: 'question',
|
||||
question: 'Deploy?',
|
||||
options: [{ id: 'a', label: 'Yes' }],
|
||||
freeTextQuestionId: 'q-free',
|
||||
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 5 }
|
||||
},
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'working',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' },
|
||||
providerFrame: { provider: 'codex', kind: 'raw', payload }
|
||||
}
|
||||
].map((body, index) => ({
|
||||
itemId: `codex:thread-1:turn-1:${index + 1}`,
|
||||
revision: 1,
|
||||
body: body as JournalSnapshotFile['items'][number]['body'],
|
||||
sequence: index + 1,
|
||||
observedAt: 1_000,
|
||||
...(index === 0 ? { recovered: true as const } : {})
|
||||
}))
|
||||
snapshot.compactedThrough = snapshot.items.length
|
||||
snapshot.submissions = [
|
||||
{
|
||||
clientMessageId: 'm-1',
|
||||
fence: 1,
|
||||
payloadFingerprint: 'a'.repeat(64),
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: 'codex:thread-1:turn-1:1',
|
||||
reason: null,
|
||||
submittedAt: 1_000,
|
||||
resolvedAt: 1_001
|
||||
}
|
||||
]
|
||||
await writeSnapshot(snapshot)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('valid')
|
||||
})
|
||||
|
||||
it('classifies a JSON-valid non-array tombstones collection as invalid instead of valid', async () => {
|
||||
await writeSnapshot({ ...validSnapshot(), tombstones: {} })
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects tombstone entries that would poison seeding', async () => {
|
||||
for (const tombstones of [
|
||||
[{ itemId: 42, revision: 1 }],
|
||||
[{ itemId: 'codex:thread-1:turn-1:1', revision: 'one' }],
|
||||
[{ itemId: 'codex:thread-1:turn-1:1', revision: Number.NaN }],
|
||||
['codex:thread-1:turn-1:1']
|
||||
]) {
|
||||
await writeSnapshot({ ...validSnapshot(), tombstones })
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects JSON-valid nested item corruption instead of admitting it', async () => {
|
||||
// A resolved question with `options: null` used to pass shallow admission and
|
||||
// then throw `TypeError` in the shared projection's `options.map`.
|
||||
const poisonedQuestion = validSnapshot()
|
||||
poisonedQuestion.items = [
|
||||
{
|
||||
itemId: 'codex:thread-1:turn-1:1',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: 'Deploy?',
|
||||
options: null,
|
||||
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
|
||||
},
|
||||
sequence: 2,
|
||||
observedAt: 1_000
|
||||
}
|
||||
] as unknown as JournalSnapshotFile['items']
|
||||
await writeSnapshot(poisonedQuestion)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
|
||||
// Pending-prompt surfaces read `resolution.state` before anything else.
|
||||
const nullResolution = validSnapshot()
|
||||
nullResolution.items = [
|
||||
{
|
||||
itemId: 'codex:thread-1:turn-1:1',
|
||||
revision: 1,
|
||||
body: { kind: 'question', question: 'Deploy?', options: [], resolution: null },
|
||||
sequence: 2,
|
||||
observedAt: 1_000
|
||||
}
|
||||
] as unknown as JournalSnapshotFile['items']
|
||||
await writeSnapshot(nullResolution)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects a JSON-valid nested corruption in the retained tail', async () => {
|
||||
const poisonedTail = validSnapshot()
|
||||
poisonedTail.tail = [
|
||||
{
|
||||
v: 1,
|
||||
epoch: 'epoch-A',
|
||||
seq: 3,
|
||||
fence: 1,
|
||||
ts: 1_000,
|
||||
kind: 'item',
|
||||
itemId: 'codex:thread-1:turn-1:3',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: 'Deploy?',
|
||||
options: null,
|
||||
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
|
||||
}
|
||||
}
|
||||
] as unknown as JournalSnapshotFile['tail']
|
||||
await writeSnapshot(poisonedTail)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects a submission that only carries a client message id', async () => {
|
||||
const shallowSubmission = validSnapshot()
|
||||
shallowSubmission.submissions = [
|
||||
{ clientMessageId: 'm-1' }
|
||||
] as unknown as JournalSnapshotFile['submissions']
|
||||
await writeSnapshot(shallowSubmission)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects items and counters that only look shallowly plausible', async () => {
|
||||
const missingSequence = validSnapshot()
|
||||
missingSequence.items = [
|
||||
{ itemId: 'codex:thread-1:turn-1:1', revision: 1, body: { kind: 'status', text: 'x' } }
|
||||
] as unknown as JournalSnapshotFile['items']
|
||||
await writeSnapshot(missingSequence)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
|
||||
await writeSnapshot({ ...validSnapshot(), compactedThrough: Number.NaN })
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('future-version snapshot classification', () => {
|
||||
it('classifies a future version before shape validation so unknown bodies stay unreadable', async () => {
|
||||
// The version can only advance because bodies changed, so a future snapshot
|
||||
// legitimately carries kinds this build cannot parse. That is the
|
||||
// schema-unreadable contract, not corruption.
|
||||
const future = validSnapshot() as unknown as Record<string, unknown>
|
||||
future.v = 99
|
||||
future.items = [
|
||||
{
|
||||
itemId: 'codex:thread-1:turn-1:1',
|
||||
revision: 1,
|
||||
body: { kind: 'future-render-kind', payload: { anything: true } },
|
||||
sequence: 2,
|
||||
observedAt: 1_000
|
||||
}
|
||||
]
|
||||
await writeSnapshot(future)
|
||||
expect((await readJournalSnapshot(root)).status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('classifies a future version as unreadable even when its shapes still parse today', async () => {
|
||||
await writeSnapshot({ ...validSnapshot(), v: 99 })
|
||||
expect((await readJournalSnapshot(root)).status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('treats a non-integer or sub-1 version as invalid, matching row admission', async () => {
|
||||
for (const v of [0, 1.5]) {
|
||||
await writeSnapshot({ ...validSnapshot(), v })
|
||||
expect((await readJournalSnapshot(root)).status).toBe('invalid')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('journal startup isolation from a malformed snapshot', () => {
|
||||
it('quarantines a JSON-valid malformed snapshot instead of throwing through open', async () => {
|
||||
await writeSnapshot({ ...validSnapshot(), tombstones: {} })
|
||||
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
},
|
||||
journalDir: root
|
||||
})
|
||||
|
||||
// Degraded exactly like other corrupt snapshots: quarantined on disk, never
|
||||
// silently deleted, and the session does not adopt state it cannot trust.
|
||||
const entries = await readdir(root)
|
||||
expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true)
|
||||
expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(false)
|
||||
expect(journal.snapshot().items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reopen after a persisted JSON-valid poisoned question', () => {
|
||||
it('quarantines the snapshot so reopen-to-render cannot throw in projection', async () => {
|
||||
const poisoned = validSnapshot()
|
||||
poisoned.items = [
|
||||
{
|
||||
itemId: 'codex:thread-1:turn-1:1',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'question',
|
||||
question: 'Deploy?',
|
||||
options: null,
|
||||
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
|
||||
},
|
||||
sequence: 2,
|
||||
observedAt: 1_000
|
||||
}
|
||||
] as unknown as JournalSnapshotFile['items']
|
||||
await writeSnapshot(poisoned)
|
||||
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
},
|
||||
journalDir: root
|
||||
})
|
||||
|
||||
// The poisoned item must land in quarantine, not in the reopened state:
|
||||
// pre-fix it was admitted and the render path below threw
|
||||
// `TypeError: Cannot read properties of null (reading 'map')`.
|
||||
const entries = await readdir(root)
|
||||
expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true)
|
||||
const items = journal.snapshot().items
|
||||
expect(() => projectStructuredItemsToNativeChat(items)).not.toThrow()
|
||||
expect(() => projectStructuredAgentSessionStatus(items)).not.toThrow()
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendJournalRows directory fsync', () => {
|
||||
const ROW: JournalRow = {
|
||||
kind: 'epoch',
|
||||
reason: 'session_created',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' },
|
||||
v: 1,
|
||||
epoch: 'epoch-A',
|
||||
seq: 1,
|
||||
fence: 0,
|
||||
ts: 1_000
|
||||
}
|
||||
|
||||
function hookDirectoryOpen(sync: ReturnType<typeof vi.fn>): FakeDirectoryHandle {
|
||||
const fake: FakeDirectoryHandle = { sync, close: vi.fn(async () => undefined) }
|
||||
openDirectoryHook = (path, flags) => (path === root && flags === 'r' ? fake : undefined)
|
||||
return fake
|
||||
}
|
||||
|
||||
it('closes the directory handle when directory fsync fails', async () => {
|
||||
const fake = hookDirectoryOpen(
|
||||
vi.fn(async () => {
|
||||
throw new Error('EINVAL: sync')
|
||||
})
|
||||
)
|
||||
|
||||
// Tolerating unsupported directory fsync must not turn into a leak.
|
||||
await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined()
|
||||
expect(fake.sync).toHaveBeenCalledTimes(1)
|
||||
expect(fake.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes the directory handle when directory fsync succeeds', async () => {
|
||||
const fake = hookDirectoryOpen(vi.fn(async () => undefined))
|
||||
|
||||
await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined()
|
||||
expect(fake.sync).toHaveBeenCalledTimes(1)
|
||||
expect(fake.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,365 +0,0 @@
|
||||
// On-disk layout for one session's journal.
|
||||
//
|
||||
// <journal dir>/log.jsonl append-only rows, fsynced before the caller is told the write landed
|
||||
// <journal dir>/snapshot.json folded state at a compaction boundary PLUS the retained tail
|
||||
// <journal dir>/blobs/<sha256> bounded-payload remainders
|
||||
//
|
||||
// The snapshot carries its own tail so compaction is one atomic write. A crash
|
||||
// between publishing the snapshot and truncating the log leaves the log a
|
||||
// superset of the tail, and recovery unions the two by sequence — never a hole.
|
||||
|
||||
import { appendFile, mkdir, open, readFile, stat, type FileHandle } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { durableWriteTempPath, renameDurable, writeFileDurable } from '../../durable-file-write'
|
||||
import {
|
||||
AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
type AgentJournalRenderItem,
|
||||
type AgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
isAdmissibleAgentJournalRenderItem,
|
||||
isAdmissibleAgentJournalSubmission
|
||||
} from '../../../shared/agent-session-journal-schemas'
|
||||
import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema'
|
||||
import { assertJournalPhysicalCapacity } from './journal-physical-quota'
|
||||
|
||||
export const JOURNAL_LOG_FILE = 'log.jsonl'
|
||||
export const JOURNAL_SNAPSHOT_FILE = 'snapshot.json'
|
||||
|
||||
export type JournalSnapshotFile = {
|
||||
v: number
|
||||
epoch: string
|
||||
/** Highest sequence folded into `items`; the tail starts after it. */
|
||||
compactedThrough: number
|
||||
/** Fence monotonicity survives compaction and restart. */
|
||||
highestFence: number
|
||||
items: AgentJournalRenderItem[]
|
||||
submissions: AgentJournalSubmission[]
|
||||
/** Receipts outlive the rows that minted them: a client reconnecting after
|
||||
* compaction must still get the same answer instead of re-sending. */
|
||||
receipts: {
|
||||
clientMessageId: string
|
||||
providerItemId: string
|
||||
epoch: string
|
||||
sequence: number
|
||||
acceptedAt: number
|
||||
}[]
|
||||
/** Provider item id → submission slot, preserved so a post-compaction echo
|
||||
* still reconciles into the bubble it belongs to. */
|
||||
aliases: { providerItemId: string; itemId: string }[]
|
||||
tombstones: { itemId: string; revision: number }[]
|
||||
/** Bounded by compaction retention; used to deduplicate a replayed settlement. */
|
||||
appliedSettlementIds?: string[]
|
||||
tail: JournalRow[]
|
||||
}
|
||||
|
||||
export type JournalReadResult = {
|
||||
rows: JournalRow[]
|
||||
/** True when a line used a schema version this build cannot read. Reading
|
||||
* STOPS there — the row must not be skipped — and the host degrades to
|
||||
* read-only: no writes, no compaction, no deletion. */
|
||||
unreadable: boolean
|
||||
/** Lines that failed to parse for reasons other than schema version. */
|
||||
malformed: number
|
||||
/** Raw suffix beginning at the first malformed line, if any. */
|
||||
remainder?: string
|
||||
/** Distinguishes an absent/empty log from bytes that could not name an epoch. */
|
||||
hasBytes: boolean
|
||||
}
|
||||
|
||||
export type JournalSnapshotReadResult =
|
||||
| { status: 'missing' }
|
||||
| { status: 'valid'; snapshot: JournalSnapshotFile }
|
||||
| { status: 'invalid' }
|
||||
/** A future schema version: unreadable by this build, not corrupt. The file
|
||||
* stays authoritative in place and the caller degrades to read-only. */
|
||||
| { status: 'unreadable' }
|
||||
|
||||
const NEWLINE_BYTE = 0x0a
|
||||
|
||||
export async function ensureJournalDir(journalDir: string): Promise<void> {
|
||||
await mkdir(journalDir, { recursive: true })
|
||||
}
|
||||
|
||||
export async function readJournalSnapshot(journalDir: string): Promise<JournalSnapshotReadResult> {
|
||||
try {
|
||||
const raw = await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const version = snapshotSchemaVersion(parsed)
|
||||
if (version === null) {
|
||||
return { status: 'invalid' }
|
||||
}
|
||||
// Version is classified BEFORE shape validation, matching row admission: a
|
||||
// version only advances because bodies changed, so a valid newer snapshot
|
||||
// carries kinds this build cannot parse — unreadable, never corruption.
|
||||
if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) {
|
||||
return { status: 'unreadable' }
|
||||
}
|
||||
return isJournalSnapshotFile(parsed)
|
||||
? { status: 'valid', snapshot: parsed }
|
||||
: { status: 'invalid' }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
return { status: 'invalid' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function quarantineInvalidJournalSnapshot(
|
||||
journalDir: string,
|
||||
quota?: { sessionId: string; maxBytes: number }
|
||||
): Promise<string> {
|
||||
// Rename is normally same-filesystem and size-neutral, but admission must
|
||||
// happen before retaining evidence so a full journal never creates an
|
||||
// unbounded quarantine artifact (or relies on a copy fallback).
|
||||
if (quota) {
|
||||
const source = join(journalDir, JOURNAL_SNAPSHOT_FILE)
|
||||
// Account for the complete source bytes: rename is usually neutral, but a
|
||||
// cross-device/filesystem fallback may briefly retain both inodes.
|
||||
const sourceBytes = await stat(source)
|
||||
.then((info) => info.size)
|
||||
.catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return 0
|
||||
}
|
||||
throw error
|
||||
})
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir,
|
||||
...quota,
|
||||
peakAdditionalBytes: sourceBytes
|
||||
})
|
||||
}
|
||||
const source = join(journalDir, JOURNAL_SNAPSHOT_FILE)
|
||||
const target = join(journalDir, `quarantine-snapshot-${Date.now()}-${randomUUID()}.json`)
|
||||
await renameDurable(source, target)
|
||||
return target
|
||||
}
|
||||
|
||||
export async function writeJournalSnapshotFile(
|
||||
journalDir: string,
|
||||
snapshot: JournalSnapshotFile
|
||||
): Promise<void> {
|
||||
const target = join(journalDir, JOURNAL_SNAPSHOT_FILE)
|
||||
await writeFileDurable(durableWriteTempPath(target), target, JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
export async function readJournalLog(journalDir: string): Promise<JournalReadResult> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(join(journalDir, JOURNAL_LOG_FILE), 'utf-8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { rows: [], unreadable: false, malformed: 0, hasBytes: false }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const rows: JournalRow[] = []
|
||||
let unreadable = false
|
||||
let malformed = 0
|
||||
const lines = raw.split('\n')
|
||||
let offset = 0
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
offset += line.length + 1
|
||||
continue
|
||||
}
|
||||
const parsed = parseJournalRow(line)
|
||||
if (parsed.ok) {
|
||||
rows.push(parsed.row)
|
||||
offset += line.length + 1
|
||||
continue
|
||||
}
|
||||
if (parsed.unreadable) {
|
||||
unreadable = true
|
||||
return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 }
|
||||
}
|
||||
malformed += 1
|
||||
return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 }
|
||||
}
|
||||
return { rows, unreadable, malformed, hasBytes: raw.length > 0 }
|
||||
}
|
||||
|
||||
/** Row admission requires an integer version of at least 1; a snapshot whose
|
||||
* version cannot even be read is malformed, not a schema statement. */
|
||||
function snapshotSchemaVersion(value: unknown): number | null {
|
||||
const snapshot = recordOf(value)
|
||||
const version = snapshot?.v
|
||||
return typeof version === 'number' && Number.isInteger(version) && version >= 1 ? version : null
|
||||
}
|
||||
|
||||
function isJournalSnapshotFile(value: unknown): value is JournalSnapshotFile {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const snapshot = value as Record<string, unknown>
|
||||
return (
|
||||
typeof snapshot.v === 'number' &&
|
||||
typeof snapshot.epoch === 'string' &&
|
||||
snapshot.epoch.length > 0 &&
|
||||
Number.isInteger(snapshot.compactedThrough) &&
|
||||
(snapshot.compactedThrough as number) >= 0 &&
|
||||
Number.isInteger(snapshot.highestFence) &&
|
||||
// Deep discriminated admission: a JSON-valid item with a corrupt nested
|
||||
// shape (e.g. a question whose options are null) must land this snapshot
|
||||
// in quarantine rather than throw later in projection or prompt render.
|
||||
arrayOf(snapshot.items, isAdmissibleAgentJournalRenderItem) &&
|
||||
arrayOf(snapshot.submissions, isAdmissibleAgentJournalSubmission) &&
|
||||
arrayOf(snapshot.receipts, isReceipt) &&
|
||||
arrayOf(snapshot.aliases, isAlias) &&
|
||||
// Older snapshots predate tombstones; absence is fine, a non-array is not —
|
||||
// seeding iterates this collection, so a JSON-valid wrong shape must land
|
||||
// in quarantine rather than throw through startup restoration.
|
||||
(snapshot.tombstones === undefined || arrayOf(snapshot.tombstones, isTombstone)) &&
|
||||
(snapshot.appliedSettlementIds === undefined ||
|
||||
arrayOf(snapshot.appliedSettlementIds, (entry) => typeof entry === 'string')) &&
|
||||
arrayOf(snapshot.tail, (row) => parseJournalRow(JSON.stringify(row)).ok)
|
||||
)
|
||||
}
|
||||
|
||||
function arrayOf(value: unknown, predicate: (entry: unknown) => boolean): value is unknown[] {
|
||||
return Array.isArray(value) && value.every(predicate)
|
||||
}
|
||||
|
||||
function recordOf(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function isTombstone(value: unknown): boolean {
|
||||
const tombstone = recordOf(value)
|
||||
return Boolean(
|
||||
tombstone && typeof tombstone.itemId === 'string' && Number.isInteger(tombstone.revision)
|
||||
)
|
||||
}
|
||||
|
||||
function isReceipt(value: unknown): boolean {
|
||||
const receipt = recordOf(value)
|
||||
return Boolean(
|
||||
receipt &&
|
||||
typeof receipt.clientMessageId === 'string' &&
|
||||
typeof receipt.providerItemId === 'string' &&
|
||||
typeof receipt.epoch === 'string' &&
|
||||
typeof receipt.sequence === 'number' &&
|
||||
typeof receipt.acceptedAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isAlias(value: unknown): boolean {
|
||||
const alias = recordOf(value)
|
||||
return Boolean(
|
||||
alias && typeof alias.providerItemId === 'string' && typeof alias.itemId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append rows and fsync before returning. The caller treats a resolved promise
|
||||
* as "this row survives a power loss" — the write-ahead submission row depends
|
||||
* on exactly that, so this must never be relaxed to a buffered write.
|
||||
*/
|
||||
export async function appendJournalRows(
|
||||
journalDir: string,
|
||||
rows: readonly JournalRow[]
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) {
|
||||
return
|
||||
}
|
||||
const path = join(journalDir, JOURNAL_LOG_FILE)
|
||||
// A process death can leave a final JSON fragment without its newline. Never
|
||||
// concatenate a new durable row onto that fragment: truncate the torn tail
|
||||
// first, then fsync the repair before acknowledging this append.
|
||||
try {
|
||||
await repairJournalLogTail(path)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
// The append below creates a missing log.
|
||||
}
|
||||
const payload = `${rows.map(serializeJournalRow).join('\n')}\n`
|
||||
await appendFile(path, payload, 'utf-8')
|
||||
const handle = await open(path, 'r+')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
let directory: FileHandle | undefined
|
||||
try {
|
||||
directory = await open(journalDir, 'r')
|
||||
await directory.sync()
|
||||
} catch {
|
||||
// Directory fsync is unavailable on some platforms (notably Windows).
|
||||
} finally {
|
||||
// The tolerance above must not leak the descriptor when open succeeded
|
||||
// but sync failed — one leaked handle per append adds up fast.
|
||||
await directory?.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** Repair only a torn final row. The normal append path reads one byte; scanning
|
||||
* backward is reserved for the crash-recovery case and never rereads the log. */
|
||||
async function repairJournalLogTail(path: string): Promise<void> {
|
||||
const handle = await open(path, 'r+')
|
||||
try {
|
||||
const { size } = await handle.stat()
|
||||
if (size === 0) {
|
||||
return
|
||||
}
|
||||
const lastByte = Buffer.alloc(1)
|
||||
await handle.read(lastByte, 0, 1, size - 1)
|
||||
if (lastByte[0] === NEWLINE_BYTE) {
|
||||
return
|
||||
}
|
||||
|
||||
const scanChunkBytes = 64 * 1024
|
||||
let scanEnd = size
|
||||
let boundary = -1
|
||||
while (scanEnd > 0 && boundary === -1) {
|
||||
const scanStart = Math.max(0, scanEnd - scanChunkBytes)
|
||||
const chunk = Buffer.alloc(scanEnd - scanStart)
|
||||
await handle.read(chunk, 0, chunk.length, scanStart)
|
||||
const newline = chunk.lastIndexOf(NEWLINE_BYTE)
|
||||
if (newline !== -1) {
|
||||
boundary = scanStart + newline
|
||||
}
|
||||
scanEnd = scanStart
|
||||
}
|
||||
|
||||
const lineStart = boundary + 1
|
||||
const finalLine = Buffer.alloc(size - lineStart)
|
||||
await handle.read(finalLine, 0, finalLine.length, lineStart)
|
||||
// A whole row that merely lost its newline is kept; a real fragment goes.
|
||||
const complete = parseJournalRow(finalLine.toString('utf-8')).ok
|
||||
await (complete ? handle.write('\n', size) : handle.truncate(lineStart))
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function quarantineJournalRemainder(
|
||||
journalDir: string,
|
||||
remainder: string
|
||||
): Promise<string> {
|
||||
const path = join(journalDir, `quarantine-${Date.now()}-${randomUUID()}.jsonl`)
|
||||
await writeFileDurable(durableWriteTempPath(path), path, remainder)
|
||||
return path
|
||||
}
|
||||
|
||||
/** Replace the log with exactly the retained tail. Runs only after the snapshot
|
||||
* carrying that tail is durable, so a crash here loses nothing. */
|
||||
export async function rewriteJournalLog(
|
||||
journalDir: string,
|
||||
rows: readonly JournalRow[]
|
||||
): Promise<void> {
|
||||
const target = join(journalDir, JOURNAL_LOG_FILE)
|
||||
const payload = rows.length ? `${rows.map(serializeJournalRow).join('\n')}\n` : ''
|
||||
await writeFileDurable(durableWriteTempPath(target), target, payload)
|
||||
}
|
||||
@@ -1,194 +1,205 @@
|
||||
// Loading a journal from disk: snapshot + log → folded state.
|
||||
// Loading a journal: the session projection names the live epoch, and that
|
||||
// epoch's rows are folded through the reducer in sequence order.
|
||||
//
|
||||
// The snapshot is authoritative for the current epoch. Log rows belonging to a
|
||||
// superseded epoch are dropped rather than merged — a crash between publishing
|
||||
// a rollover snapshot and rewriting the log is the ordinary way that happens.
|
||||
// A gap in the surviving sequence is corruption, and the caller rolls the epoch
|
||||
// There is no snapshot to anchor to and no superseded-epoch rows to drop — a
|
||||
// roll deletes them in the same transaction that publishes the new epoch. A gap
|
||||
// in the surviving sequence is corruption, and the caller rolls the epoch
|
||||
// rather than rendering a partial timeline.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { findSequenceGap } from './journal-cursor'
|
||||
import {
|
||||
quarantineInvalidJournalSnapshot,
|
||||
readJournalLog,
|
||||
readJournalSnapshot,
|
||||
type JournalSnapshotFile
|
||||
} from './journal-log-file'
|
||||
import { openJournalDatabase } from './journal-database'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import {
|
||||
applyJournalRow,
|
||||
createJournalReducerState,
|
||||
rememberAppliedSettlementId,
|
||||
type JournalReducerState
|
||||
} from './journal-reducer'
|
||||
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
|
||||
import {
|
||||
readJournalEpochRows,
|
||||
readJournalRowsAfter,
|
||||
readJournalSessionEpoch
|
||||
} from './journal-row-table'
|
||||
import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure'
|
||||
import { pendingJournalRepairSequence } from './journal-repair-marker'
|
||||
import { parseJournalRow, type JournalRow } from './journal-row-schema'
|
||||
|
||||
/** Every epoch row is sequence 1, and no compaction moves that floor. */
|
||||
const FIRST_JOURNAL_SEQUENCE = 1
|
||||
|
||||
export type JournalLoad = {
|
||||
state: JournalReducerState
|
||||
/** Rows still individually replayable, oldest first. */
|
||||
tailRows: JournalRow[]
|
||||
/** Highest sequence folded into the snapshot; the tail starts after it. */
|
||||
compactedThrough: number
|
||||
/** A future schema version was met: no writes, no compaction, no deletion. */
|
||||
/** A future schema version was met: no writes, no deletion. */
|
||||
readOnly: boolean
|
||||
/** Set when the surviving prefix is unusable and the caller must roll the epoch. */
|
||||
corrupt: boolean
|
||||
/** Log lines skipped because they failed to parse (schema-version rows are
|
||||
/** Rows skipped because their body failed to parse (future-version rows are
|
||||
* `readOnly`, never counted here). The store discloses these in the timeline. */
|
||||
malformedRows: number
|
||||
sizeBytes: number
|
||||
/** Raw unreadable suffix retained for quarantine instead of deletion. */
|
||||
quarantineRemainder?: string
|
||||
/** Directory-internal: the first sequence of an unusable suffix. The store
|
||||
* deletes from here before it accepts a write; a probe leaves it alone. */
|
||||
truncateFrom?: number
|
||||
}
|
||||
|
||||
/** Returns null when no journal exists yet for this session. */
|
||||
export async function loadJournal(
|
||||
journalDir: string,
|
||||
sessionId: string,
|
||||
quota?: { maxBytes: number }
|
||||
): Promise<JournalLoad | null> {
|
||||
const snapshotRead = await readJournalSnapshot(journalDir)
|
||||
if (snapshotRead.status === 'unreadable') {
|
||||
// Written by a newer schema: not corrupt, so never quarantined. The file
|
||||
// stays authoritative in place, and this build must not write, compact,
|
||||
// delete, or render a partial timeline from rows it cannot anchor to the
|
||||
// snapshot it cannot read.
|
||||
/**
|
||||
* Replay on a connection this function does NOT own. Returns null when the
|
||||
* session has no journal yet.
|
||||
*/
|
||||
export function replayJournal(
|
||||
db: Database.Database,
|
||||
readOnly: boolean,
|
||||
sessionId: string
|
||||
): JournalLoad | null {
|
||||
if (readOnly) {
|
||||
return emptyReadOnlyLoad(sessionId)
|
||||
}
|
||||
if (snapshotRead.status === 'invalid') {
|
||||
await quarantineInvalidJournalSnapshot(
|
||||
journalDir,
|
||||
quota ? { sessionId, maxBytes: quota.maxBytes } : undefined
|
||||
)
|
||||
}
|
||||
const snapshot = snapshotRead.status === 'valid' ? snapshotRead.snapshot : null
|
||||
const log = await readJournalLog(journalDir)
|
||||
const epoch = resolveEpoch(snapshot, log.rows)
|
||||
const epoch = readJournalSessionEpoch(db, sessionId)
|
||||
if (!epoch) {
|
||||
return snapshotRead.status === 'invalid' || log.hasBytes ? emptyReadOnlyLoad(sessionId) : null
|
||||
return null
|
||||
}
|
||||
const state = createJournalReducerState(sessionId, epoch)
|
||||
const stored = readJournalEpochRows(db, sessionId, epoch)
|
||||
// A partial repair keeps its prefix, so the surviving rows look contiguous and
|
||||
// anchored however much of the timeline it deleted. Its marker is what still
|
||||
// says otherwise, naming the sequence past which the epoch would be its own
|
||||
// history again.
|
||||
const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch)
|
||||
const rows: JournalRow[] = []
|
||||
let malformedRows = 0
|
||||
let latched = false
|
||||
let truncateFrom: number | undefined
|
||||
for (const entry of stored) {
|
||||
const parsed = parseJournalRow(entry.rowJson)
|
||||
if (parsed.ok) {
|
||||
rows.push(parsed.row)
|
||||
continue
|
||||
}
|
||||
// Reading STOPS at the first row this build cannot represent. A future
|
||||
// version latches read-only; anything else is one skipped row, disclosed.
|
||||
truncateFrom = entry.seq
|
||||
if (parsed.unreadable) {
|
||||
latched = true
|
||||
} else {
|
||||
malformedRows = 1
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const compactedThrough = snapshot?.epoch === epoch ? snapshot.compactedThrough : 0
|
||||
const state = seedState(sessionId, epoch, snapshot?.epoch === epoch ? snapshot : null)
|
||||
const liveRows = log.rows.filter((row) => row.epoch === epoch)
|
||||
let tailRows = unionBySequence(snapshot?.epoch === epoch ? snapshot.tail : [], liveRows, epoch)
|
||||
|
||||
const oldest = tailRows[0]?.seq ?? compactedThrough + 1
|
||||
// Anchored at 1, never at the first row that HAPPENS to remain: nothing trims
|
||||
// a prefix, so a missing epoch row is a hole like any other and everything
|
||||
// behind it is unanchored. Validating from `rows[0].seq` would call the
|
||||
// leftovers contiguous and leave them out of the repair that runs before
|
||||
// provider history replaces the epoch.
|
||||
const gap = findSequenceGap(
|
||||
tailRows.map((row) => row.seq),
|
||||
oldest
|
||||
rows.map((row) => row.seq),
|
||||
FIRST_JOURNAL_SEQUENCE
|
||||
)
|
||||
// A hole below the snapshot boundary is unrecoverable too: the snapshot only
|
||||
// covers `compactedThrough`, so a tail that starts above it lost rows.
|
||||
let corrupt = Boolean(gap) || oldest > compactedThrough + 1 || log.malformed > 0
|
||||
let quarantineRemainder = log.remainder
|
||||
if (gap) {
|
||||
const firstBad = tailRows.findIndex((row, index) => {
|
||||
const expected = (tailRows[0]?.seq ?? compactedThrough + 1) + index
|
||||
return row.seq !== expected
|
||||
})
|
||||
const firstBad = rows.findIndex((row, index) => row.seq !== FIRST_JOURNAL_SEQUENCE + index)
|
||||
if (firstBad !== -1) {
|
||||
const suffix = tailRows.slice(firstBad)
|
||||
tailRows = tailRows.slice(0, firstBad)
|
||||
quarantineRemainder ??= `${suffix.map((row) => JSON.stringify(row)).join('\n')}\n`
|
||||
truncateFrom = rows[firstBad]?.seq ?? truncateFrom
|
||||
rows.length = firstBad
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of tailRows) {
|
||||
if (row.seq > compactedThrough) {
|
||||
applyJournalRow(state, row)
|
||||
}
|
||||
// Contiguity from 1 is not the whole invariant: sequence 1 has to BE the epoch
|
||||
// row. An ordinary row there is an epoch nothing anchors, and replaying it as
|
||||
// clean is how a repaired journal silently adopts a timeline whose real
|
||||
// history was never rebuilt.
|
||||
if (rows.length > 0 && rows[0]?.kind !== 'epoch') {
|
||||
truncateFrom = rows[0]?.seq ?? truncateFrom
|
||||
rows.length = 0
|
||||
}
|
||||
state.oldestSequence = oldest
|
||||
state.lastSequence = Math.max(state.lastSequence, compactedThrough)
|
||||
for (const row of rows) {
|
||||
applyJournalRow(state, row)
|
||||
}
|
||||
state.oldestSequence = FIRST_JOURNAL_SEQUENCE
|
||||
|
||||
// A latched journal reduces to nothing by design; only a writable one can be
|
||||
// held to the anchor.
|
||||
const unanchored = !latched && rows[0]?.kind !== 'epoch'
|
||||
return {
|
||||
state,
|
||||
tailRows,
|
||||
compactedThrough,
|
||||
// A future-version snapshot never reaches here: it is classified
|
||||
// unreadable above, so `valid` implies a version this build can write.
|
||||
readOnly: log.unreadable,
|
||||
corrupt,
|
||||
malformedRows: log.malformed,
|
||||
sizeBytes: tailRows.reduce((total, row) => total + journalRowByteLength(row), 0),
|
||||
quarantineRemainder
|
||||
readOnly: latched,
|
||||
corrupt:
|
||||
Boolean(gap) ||
|
||||
malformedRows > 0 ||
|
||||
unanchored ||
|
||||
(repairedFrom !== null && awaitsRebuild(rows, repairedFrom)) ||
|
||||
awaitsProviderHistory(rows),
|
||||
malformedRows,
|
||||
...(truncateFrom !== undefined && !latched ? { truncateFrom } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The epoch a total repair published, still holding nothing but its own anchor
|
||||
* and disclosure. The rows it dropped were never reconstructed, so provider
|
||||
* history has to be retried rather than this being called a clean timeline.
|
||||
*/
|
||||
function awaitsProviderHistory(rows: readonly JournalRow[]): boolean {
|
||||
const anchor = rows[0]
|
||||
if (anchor?.kind !== 'epoch' || anchor.reason !== 'unreconcilable_prefix') {
|
||||
return false
|
||||
}
|
||||
// The anchor sits at sequence 1, so content of the epoch's own starts at 2.
|
||||
return awaitsRebuild(rows, FIRST_JOURNAL_SEQUENCE + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* True while everything at or above `contentFrom` is the repair's own
|
||||
* bookkeeping: the deleted history was never rebuilt, so the provider has to be
|
||||
* asked again. The moment the session writes content of its own past that
|
||||
* sequence the epoch IS its own history, and the retry stops rather than a
|
||||
* later import replacing rows the user has since seen.
|
||||
*/
|
||||
function awaitsRebuild(rows: readonly JournalRow[], contentFrom: number): boolean {
|
||||
return rows.every(
|
||||
(row) =>
|
||||
row.seq < contentFrom ||
|
||||
(row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID)
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows after a cursor, in sequence order. Stops at the first row this build
|
||||
* cannot parse, exactly as replay does. */
|
||||
export function readJournalRowsAfterCursor(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
afterSequence: number
|
||||
): JournalRow[] {
|
||||
const rows: JournalRow[] = []
|
||||
for (const stored of readJournalRowsAfter(db, sessionId, epoch, afterSequence)) {
|
||||
const parsed = parseJournalRow(stored.rowJson)
|
||||
if (!parsed.ok) {
|
||||
break
|
||||
}
|
||||
rows.push(parsed.row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Standalone probe. Opens its own connection and closes it before returning,
|
||||
* so a caller holding only the returned value holds no handle. */
|
||||
export function loadJournal(journalDir: string, sessionId: string): JournalLoad | null {
|
||||
const dbPath = journalDatabaseFile(journalDir)
|
||||
if (!existsSync(dbPath)) {
|
||||
return null
|
||||
}
|
||||
const opened = openJournalDatabase(dbPath)
|
||||
try {
|
||||
return replayJournal(opened.db, opened.readOnly, sessionId)
|
||||
} finally {
|
||||
opened.db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function emptyReadOnlyLoad(sessionId: string): JournalLoad {
|
||||
const state = createJournalReducerState(sessionId, '')
|
||||
return {
|
||||
state,
|
||||
tailRows: [],
|
||||
compactedThrough: 0,
|
||||
state: createJournalReducerState(sessionId, ''),
|
||||
readOnly: true,
|
||||
corrupt: false,
|
||||
malformedRows: 0,
|
||||
sizeBytes: 0
|
||||
malformedRows: 0
|
||||
}
|
||||
}
|
||||
|
||||
/** The snapshot names the live epoch; without one, the newest valid row does. */
|
||||
function resolveEpoch(snapshot: JournalSnapshotFile | null, rows: JournalRow[]): string | null {
|
||||
if (snapshot?.epoch) {
|
||||
return snapshot.epoch
|
||||
}
|
||||
return rows.at(-1)?.epoch ?? null
|
||||
}
|
||||
|
||||
function seedState(
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
snapshot: JournalSnapshotFile | null
|
||||
): JournalReducerState {
|
||||
const state = createJournalReducerState(sessionId, epoch)
|
||||
if (!snapshot) {
|
||||
return state
|
||||
}
|
||||
for (const item of snapshot.items) {
|
||||
state.items.set(item.itemId, item)
|
||||
}
|
||||
for (const submission of snapshot.submissions) {
|
||||
state.submissions.set(submission.clientMessageId, { ...submission } as AgentJournalSubmission)
|
||||
}
|
||||
for (const receipt of snapshot.receipts) {
|
||||
state.receipts.set(receipt.clientMessageId, {
|
||||
clientMessageId: receipt.clientMessageId,
|
||||
providerItemId: receipt.providerItemId,
|
||||
cursor: { epoch: receipt.epoch, sequence: receipt.sequence },
|
||||
acceptedAt: receipt.acceptedAt
|
||||
})
|
||||
}
|
||||
for (const alias of snapshot.aliases) {
|
||||
state.aliases.set(alias.providerItemId, alias.itemId)
|
||||
}
|
||||
for (const tombstone of snapshot.tombstones ?? []) {
|
||||
state.tombstones.set(tombstone.itemId, tombstone.revision)
|
||||
}
|
||||
for (const settlementId of snapshot.appliedSettlementIds ?? []) {
|
||||
rememberAppliedSettlementId(state, settlementId)
|
||||
}
|
||||
state.highestFence = snapshot.highestFence ?? 0
|
||||
state.lastSequence = snapshot.compactedThrough
|
||||
state.oldestSequence = snapshot.compactedThrough + 1
|
||||
return state
|
||||
}
|
||||
|
||||
/** Merge the snapshot's retained tail with the live log, preferring the log's
|
||||
* copy of any sequence both hold, and dropping rows from a superseded epoch. */
|
||||
function unionBySequence(
|
||||
retained: readonly JournalRow[],
|
||||
live: readonly JournalRow[],
|
||||
epoch: string
|
||||
): JournalRow[] {
|
||||
const bySequence = new Map<number, JournalRow>()
|
||||
for (const row of retained) {
|
||||
if (row.epoch === epoch) {
|
||||
bySequence.set(row.seq, row)
|
||||
}
|
||||
}
|
||||
for (const row of live) {
|
||||
bySequence.set(row.seq, row)
|
||||
}
|
||||
return [...bySequence.values()].sort((a, b) => a.seq - b.seq)
|
||||
}
|
||||
|
||||
@@ -40,3 +40,10 @@ export function journalDirectoryFor(
|
||||
export function defaultJournalRoot(): Promise<string> {
|
||||
return Promise.resolve(getAppEnvironment().getPath('userData'))
|
||||
}
|
||||
|
||||
export const JOURNAL_DATABASE_FILE = 'journal.db'
|
||||
|
||||
/** The session's SQLite database, inside the directory `journalDirectoryFor` names. */
|
||||
export function journalDatabaseFile(journalDir: string): string {
|
||||
return join(journalDir, JOURNAL_DATABASE_FILE)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// Payload bounds for tool output and diffs.
|
||||
//
|
||||
// A looping agent must not be able to fill the host disk, and a 40 MB tool
|
||||
// result must not be inlined into a row that every reconnecting client
|
||||
// replays. A bounded payload keeps a head plus the original byte length and
|
||||
// digest; the remainder lives in the content-addressed blob store under the
|
||||
// same retention as its epoch. Crossing a bound is always marked — never a
|
||||
// silent drop.
|
||||
// A 40 MB tool result must not be inlined into a row that every reconnecting
|
||||
// client replays. A bounded payload keeps a head plus the original byte length
|
||||
// and digest; the remainder is discarded. Crossing a bound is always marked —
|
||||
// never a silent drop.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types'
|
||||
@@ -13,18 +11,10 @@ import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-j
|
||||
export type JournalPayloadLimits = {
|
||||
/** Bytes of the payload kept inline on the row. */
|
||||
inlineHeadBytes: number
|
||||
/** Total bytes of journal rows one session may hold before appends are refused. */
|
||||
maxSessionBytes: number
|
||||
/** Appends allowed inside `appendWindowMs`, bounding a runaway agent's rate. */
|
||||
maxAppendsPerWindow: number
|
||||
appendWindowMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_JOURNAL_PAYLOAD_LIMITS: JournalPayloadLimits = {
|
||||
inlineHeadBytes: 16 * 1024,
|
||||
maxSessionBytes: 256 * 1024 * 1024,
|
||||
maxAppendsPerWindow: 5000,
|
||||
appendWindowMs: 60_000
|
||||
inlineHeadBytes: 16 * 1024
|
||||
}
|
||||
|
||||
/** Marker appended to a clipped inline string so the UI never presents a
|
||||
@@ -38,10 +28,8 @@ export function digestPayload(payload: string): string {
|
||||
return createHash('sha256').update(payload, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip `payload` to the inline head. `truncated` means the remainder must be
|
||||
* written to the blob store under `digest` before the row is appended.
|
||||
*/
|
||||
/** Clip `payload` to the inline head. `truncated` means the remainder was
|
||||
* discarded; `digest` and `byteLength` describe the original. */
|
||||
export function boundPayload(
|
||||
payload: string,
|
||||
limits: JournalPayloadLimits
|
||||
@@ -60,7 +48,7 @@ export function boundPayload(
|
||||
}
|
||||
|
||||
/** Bound a plain string that must stay a string (a tool-result block's output),
|
||||
* keeping the explicit marker inline. Returns the blob payload to persist. */
|
||||
* keeping the explicit marker inline. */
|
||||
export function boundInlineText(
|
||||
payload: string,
|
||||
limits: JournalPayloadLimits
|
||||
@@ -75,7 +63,7 @@ export function boundInlineText(
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep arbitrary tool input JSON bounded before lifecycle admission. */
|
||||
/** Keep arbitrary tool input JSON bounded before it reaches a row. */
|
||||
export function boundToolInput(input: unknown, limits: JournalPayloadLimits): unknown {
|
||||
let encoded: string
|
||||
try {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { mkdtemp, readdir, readFile, 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 type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { JOURNAL_SNAPSHOT_FILE } from './journal-log-file'
|
||||
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { journalDirectoryBytes } from './journal-physical-quota'
|
||||
import { openAgentSessionJournal } from './journal-store-factory'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
|
||||
let root: string
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function body(value: string): AgentJournalItemBody {
|
||||
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-quota-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('journal physical quota peaks', () => {
|
||||
it('refuses an epoch replacement whose staging peak exceeds the quota', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 8_000 }
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
await journal.appendItem(item(1), body('old'.repeat(500)), { fence: 1 })
|
||||
const epoch = journal.epoch
|
||||
|
||||
await expect(
|
||||
journal.replaceEpochItems('handle_forked', 2, [
|
||||
{ identity: item(2), body: body('replacement'.repeat(250)) }
|
||||
])
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
|
||||
expect(journal.epoch).toBe(epoch)
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
|
||||
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
|
||||
})
|
||||
|
||||
it('refuses schema quarantine when its peak copy would exceed the quota', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 7_000 }
|
||||
await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
|
||||
const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE)
|
||||
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record<string, unknown>
|
||||
snapshot.v = 99
|
||||
snapshot.items = [{ body: { kind: 'future', payload: 'x'.repeat(4_000) } }]
|
||||
await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8')
|
||||
const reopened = await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
|
||||
|
||||
await expect(reopened.rollEpoch('schema_unreadable', 2)).rejects.toMatchObject({
|
||||
code: 'journal_bound_exceeded'
|
||||
})
|
||||
|
||||
expect(reopened.isReadOnly).toBe(true)
|
||||
expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(false)
|
||||
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
|
||||
})
|
||||
|
||||
it('does not rename an invalid snapshot when the directory is already full', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 }
|
||||
await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
|
||||
const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE)
|
||||
await writeFile(snapshotPath, '{"invalid":', 'utf8')
|
||||
const current = await journalDirectoryBytes(root)
|
||||
await writeFile(
|
||||
join(root, 'quota-filler'),
|
||||
'x'.repeat(Math.max(0, limits.maxSessionBytes - current)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
await expect(
|
||||
openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
expect(await readFile(snapshotPath, 'utf8')).toBe('{"invalid":')
|
||||
expect((await readdir(root)).some((name) => name.startsWith('quarantine-snapshot-'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('counts pre-existing durable-write temps while staging an epoch replacement', async () => {
|
||||
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 8_000 }
|
||||
const journal = await openAgentSessionJournal({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
limits,
|
||||
autoCompact: false
|
||||
})
|
||||
await journal.appendItem(item(1), body('old'), { fence: 1 })
|
||||
// Simulate a temp left by a crash. Replacement must refuse before writing
|
||||
// its epoch row or creating a staging blob beside this file.
|
||||
await writeFile(join(root, 'snapshot.json.crashed-write.tmp'), 'x'.repeat(7_500), 'utf8')
|
||||
const epoch = journal.epoch
|
||||
|
||||
await expect(
|
||||
journal.replaceEpochItems('handle_forked', 2, [{ identity: item(2), body: body('new') }])
|
||||
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
|
||||
|
||||
expect(journal.epoch).toBe(epoch)
|
||||
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
|
||||
/** Counts every physical file owned by one session, including blobs, durable
|
||||
* write temps, and retained quarantine evidence. Symlinks are charged as files
|
||||
* but never followed outside the journal directory. */
|
||||
export async function journalDirectoryBytes(directory: string): Promise<number> {
|
||||
let entries: Dirent<string>[]
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true, encoding: 'utf8' })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return 0
|
||||
}
|
||||
throw error
|
||||
}
|
||||
let total = 0
|
||||
for (const entry of entries) {
|
||||
const path = join(directory, entry.name)
|
||||
total += entry.isDirectory() ? await journalDirectoryBytes(path) : (await lstat(path)).size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
export async function assertJournalPhysicalCapacity(input: {
|
||||
journalDir: string
|
||||
sessionId: string
|
||||
maxBytes: number
|
||||
peakAdditionalBytes?: number
|
||||
}): Promise<number> {
|
||||
const current = await journalDirectoryBytes(input.journalDir)
|
||||
if (current + (input.peakAdditionalBytes ?? 0) > input.maxBytes) {
|
||||
throw new AgentSessionJournalError(
|
||||
'journal_bound_exceeded',
|
||||
`agent-session journal for ${input.sessionId} reached its ${input.maxBytes}-byte physical bound`
|
||||
)
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
|
||||
export const MAX_JOURNAL_PROMPT_OPTIONS = 64
|
||||
|
||||
const JOURNAL_PROMPT_OPTION_LIMITS = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 1024
|
||||
}
|
||||
const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 }
|
||||
const JOURNAL_PROMPT_ID_MAX_BYTES = 1024
|
||||
|
||||
export function cancelledJournalPromptBody(
|
||||
@@ -78,11 +75,6 @@ function boundPromptIdentifier(value: string): string {
|
||||
if (Buffer.byteLength(value, 'utf8') <= JOURNAL_PROMPT_ID_MAX_BYTES) {
|
||||
return value
|
||||
}
|
||||
const bounded = boundPayload(value, {
|
||||
inlineHeadBytes: JOURNAL_PROMPT_ID_MAX_BYTES - 33,
|
||||
maxSessionBytes: Number.MAX_SAFE_INTEGER,
|
||||
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
|
||||
appendWindowMs: Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
const bounded = boundPayload(value, { inlineHeadBytes: JOURNAL_PROMPT_ID_MAX_BYTES - 33 })
|
||||
return `${bounded.head}#${bounded.digest.slice(0, 32)}`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
applyJournalRow,
|
||||
createJournalReducerState,
|
||||
MAX_JOURNAL_APPLIED_SETTLEMENT_IDS,
|
||||
referencedBlobDigests,
|
||||
renderJournalState,
|
||||
type JournalReducerState
|
||||
} from './journal-reducer'
|
||||
@@ -379,58 +378,6 @@ describe('lifecycle settlement deduplication', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('blob retention', () => {
|
||||
it('reports the digests live rows still reference', () => {
|
||||
const state = fold([
|
||||
{
|
||||
kind: 'item',
|
||||
itemId: 'tool',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'bash',
|
||||
input: {},
|
||||
state: 'completed',
|
||||
output: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true }
|
||||
},
|
||||
...base(1)
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
itemId: 'inline',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'bash',
|
||||
input: {},
|
||||
state: 'completed',
|
||||
output: { head: 'y', byteLength: 1, digest: 'digest-b', truncated: false }
|
||||
},
|
||||
...base(2)
|
||||
}
|
||||
])
|
||||
expect([...referencedBlobDigests(state)]).toEqual(['digest-a'])
|
||||
})
|
||||
|
||||
it('stops referencing a digest once its item is tombstoned', () => {
|
||||
const state = fold([
|
||||
{
|
||||
kind: 'item',
|
||||
itemId: 'tool',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'diff',
|
||||
path: 'a.ts',
|
||||
patch: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true }
|
||||
},
|
||||
...base(1)
|
||||
},
|
||||
{ kind: 'tombstone', itemId: 'tool', revision: 2, ...base(2) }
|
||||
])
|
||||
expect(referencedBlobDigests(state).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('malformed persisted item keys', () => {
|
||||
it('degrades a malformed-percent item id to an opaque key instead of throwing', () => {
|
||||
// A user-message body drives identity resolution through the key parser;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
import type {
|
||||
AgentJournalAcceptanceReceipt,
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSnapshot,
|
||||
AgentJournalSubmission
|
||||
@@ -272,26 +271,3 @@ export function renderJournalState(state: JournalReducerState): AgentJournalSnap
|
||||
submissions: [...state.submissions.values()].sort((a, b) => a.submittedAt - b.submittedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/** Blob digests one body points at. A retained row can outlive its render item
|
||||
* (a tombstone drops the item), so compaction reads rows through this too. */
|
||||
export function blobDigestsInBody(body: AgentJournalItemBody, into: Set<string>): void {
|
||||
if (body.kind === 'tool-call' && body.output?.truncated) {
|
||||
into.add(body.output.digest)
|
||||
}
|
||||
if (body.kind === 'diff' && body.patch.truncated) {
|
||||
into.add(body.patch.digest)
|
||||
}
|
||||
if (body.kind === 'status' && body.providerFrame?.payload.truncated) {
|
||||
into.add(body.providerFrame.payload.digest)
|
||||
}
|
||||
}
|
||||
|
||||
/** Digests referenced by live rows, so compaction knows which blobs to keep. */
|
||||
export function referencedBlobDigests(state: JournalReducerState): Set<string> {
|
||||
const digests = new Set<string>()
|
||||
for (const item of state.items.values()) {
|
||||
blobDigestsInBody(item.body, digests)
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// What a repair tells the user it did.
|
||||
//
|
||||
// The identity is a constant because replay reads it back: an epoch holding
|
||||
// nothing but its anchor and this row is a repair that has not been
|
||||
// reconstructed yet, not a timeline.
|
||||
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
|
||||
|
||||
/** One stable identity, so a reopen upserts the same row instead of adding one. */
|
||||
export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
|
||||
provider: 'orca',
|
||||
clientMessageId: 'journal-malformed-lines'
|
||||
}
|
||||
|
||||
export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey(
|
||||
JOURNAL_REPAIR_DISCLOSURE_IDENTITY
|
||||
)
|
||||
|
||||
export type JournalRepairDisclosure = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: { kind: 'status'; text: string }
|
||||
}
|
||||
|
||||
/** Disclosed when a repair skipped a row it could not read. */
|
||||
export function journalRepairDisclosure(input: { malformedRows: number }): JournalRepairDisclosure {
|
||||
const lines = `${input.malformedRows} journal line${input.malformedRows === 1 ? '' : 's'}`
|
||||
return {
|
||||
identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY,
|
||||
body: { kind: 'status', text: `${lines} could not be read` }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// The standing demand for a rebuild that a repair leaves behind.
|
||||
//
|
||||
// A repair that empties the epoch republishes an `unreconcilable_prefix` anchor,
|
||||
// and replay reads that back as history still owed. A repair that KEEPS a prefix
|
||||
// has no such anchor to publish and — for a plain sequence gap — no malformed
|
||||
// row to disclose either, so nothing on disk would record that the deleted
|
||||
// suffix was never reconstructed. This marker is that record, written in the
|
||||
// SAME transaction as the deletion: a crash between the two would otherwise
|
||||
// leave the rows gone with nothing left asking for them back.
|
||||
//
|
||||
// It records the first sequence at which the epoch would hold content of its
|
||||
// own again, because it retires under exactly the rule the emptied-epoch anchor
|
||||
// takes: a fresh epoch carries the rebuild, and a session that writes past that
|
||||
// sequence owns the epoch and stops the retry.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { deleteJournalRowSuffix } from './journal-row-table'
|
||||
|
||||
const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?'
|
||||
const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at`
|
||||
const DELETE_REPAIR = 'DELETE FROM journal_repairs WHERE session_id = ?'
|
||||
|
||||
/**
|
||||
* The sequence a pending repair on THIS epoch left free, or null when none is
|
||||
* pending. Epoch-scoped: a marker raised on an epoch that has since been
|
||||
* superseded says nothing about the live one.
|
||||
*/
|
||||
export function pendingJournalRepairSequence(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string
|
||||
): number | null {
|
||||
const row = db.prepare(SELECT_REPAIR).get(sessionId) as
|
||||
| { epoch?: string; content_from?: number }
|
||||
| undefined
|
||||
return row?.epoch === epoch ? (row.content_from ?? null) : null
|
||||
}
|
||||
|
||||
/** Retires the marker. Called from inside the epoch transactions, whose new
|
||||
* epoch is the rebuilt history the marker was holding out for. */
|
||||
export function clearJournalRepairMarker(db: Database.Database, sessionId: string): void {
|
||||
db.prepare(DELETE_REPAIR).run(sessionId)
|
||||
}
|
||||
|
||||
/** Drop the rejected suffix and record that it is owed, atomically. */
|
||||
export function deleteJournalRepairedSuffix(input: {
|
||||
db: Database.Database
|
||||
sessionId: string
|
||||
epoch: string
|
||||
/** First sequence of the rejected suffix. */
|
||||
fromSeq: number
|
||||
/** First sequence left free once the suffix is gone. */
|
||||
contentFrom: number
|
||||
now: number
|
||||
}): number {
|
||||
input.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const deleted = deleteJournalRowSuffix(input.db, input.sessionId, input.epoch, input.fromSeq)
|
||||
input.db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now)
|
||||
input.db.exec('COMMIT')
|
||||
return deleted
|
||||
} catch (error) {
|
||||
input.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Every statement the journal issues against `journal_rows` / `journal_sessions`.
|
||||
//
|
||||
// Each one is a prefix or range scan on the `(session_id, epoch, seq)` primary
|
||||
// key; there is no secondary index, and no `max(seq)` tip query — replay folds
|
||||
// the epoch to obtain `lastSequence`, so nothing needs the tip from SQL.
|
||||
// Columns are always named: `SELECT *` is uncacheable and can drop a column.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { serializeJournalRow, type JournalRow } from './journal-row-schema'
|
||||
|
||||
export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string }
|
||||
|
||||
const SELECT_SESSION = 'SELECT epoch FROM journal_sessions WHERE session_id = ?'
|
||||
const UPSERT_SESSION = `INSERT INTO journal_sessions (session_id, epoch, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, updated_at = excluded.updated_at`
|
||||
const INSERT_ROW =
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
const SELECT_EPOCH_ROWS = `SELECT epoch, seq, ts, row_json FROM journal_rows
|
||||
WHERE session_id = ? AND epoch = ? ORDER BY seq ASC`
|
||||
const SELECT_ROWS_AFTER = `SELECT epoch, seq, ts, row_json FROM journal_rows
|
||||
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC`
|
||||
const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?'
|
||||
|
||||
export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null {
|
||||
const row = db.prepare(SELECT_SESSION).get(sessionId) as { epoch?: string } | undefined
|
||||
return row?.epoch ?? null
|
||||
}
|
||||
|
||||
export function upsertJournalSessionRow(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
updatedAt: number
|
||||
): void {
|
||||
db.prepare(UPSERT_SESSION).run(sessionId, epoch, updatedAt)
|
||||
}
|
||||
|
||||
export function insertJournalRow(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
row: JournalRow
|
||||
): number {
|
||||
const rowJson = serializeJournalRow(row)
|
||||
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson)
|
||||
return Buffer.byteLength(rowJson, 'utf8')
|
||||
}
|
||||
|
||||
export function readJournalEpochRows(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string
|
||||
): JournalStoredRow[] {
|
||||
return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch))
|
||||
}
|
||||
|
||||
export function readJournalRowsAfter(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
afterSeq: number
|
||||
): JournalStoredRow[] {
|
||||
return toStoredRows(db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unqualified on purpose. One database per session means every row here belongs
|
||||
* to this session, and the unqualified form takes SQLite's truncate
|
||||
* optimization: measured at 0.26% of the database in WAL bytes where the
|
||||
* `WHERE session_id = ?` form rewrote every emptied leaf at up to 99%.
|
||||
*/
|
||||
export function deleteAllJournalRows(db: Database.Database): void {
|
||||
db.exec('DELETE FROM journal_rows')
|
||||
}
|
||||
|
||||
/** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */
|
||||
export function deleteJournalRowSuffix(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
epoch: string,
|
||||
fromSeq: number
|
||||
): number {
|
||||
const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq)
|
||||
return Number(deleted.changes ?? 0)
|
||||
}
|
||||
|
||||
function toStoredRows(rows: readonly unknown[]): JournalStoredRow[] {
|
||||
return rows.map((entry) => {
|
||||
const record = entry as { epoch: string; seq: number; ts: number; row_json: string }
|
||||
return { epoch: record.epoch, seq: record.seq, ts: record.ts, rowJson: record.row_json }
|
||||
})
|
||||
}
|
||||
-362
@@ -1,362 +0,0 @@
|
||||
import { 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 type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
|
||||
import { readJournalBlob } from './journal-blob-store'
|
||||
import { appendJournalRows } from './journal-log-file'
|
||||
import { JournalLifecycleAdmission } from './journal-lifecycle-admission'
|
||||
import { JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES } from './journal-lifecycle-capacity'
|
||||
import { loadJournal } from './journal-open'
|
||||
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
|
||||
import { JournalRowWriter } from './journal-row-writer'
|
||||
import { JournalAppendBudget } from './journal-write-guards'
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
|
||||
function row(seq: number, ts: number): JournalRow {
|
||||
return {
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch: 'epoch-1',
|
||||
seq,
|
||||
fence: 0,
|
||||
ts,
|
||||
kind: 'item',
|
||||
itemId: 'item-1',
|
||||
revision: 1,
|
||||
body: { kind: 'status', text: 'ambiguous append' }
|
||||
}
|
||||
}
|
||||
|
||||
function rowWithBlob(seq: number, ts: number, output: ReturnType<typeof boundPayload>): JournalRow {
|
||||
return {
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch: 'epoch-1',
|
||||
seq,
|
||||
fence: 0,
|
||||
ts,
|
||||
kind: 'item',
|
||||
itemId: 'item-with-blob',
|
||||
revision: 1,
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: {},
|
||||
state: 'completed',
|
||||
output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runningToolRow(seq: number, ts: number, itemId = 'running-tool'): JournalRow {
|
||||
return {
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch: 'epoch-1',
|
||||
seq,
|
||||
fence: 0,
|
||||
ts,
|
||||
kind: 'item',
|
||||
itemId,
|
||||
revision: 1,
|
||||
body: runningToolBody()
|
||||
}
|
||||
}
|
||||
|
||||
function runningToolBody(): AgentJournalItemBody {
|
||||
return { kind: 'tool-call', name: 'shell', input: {}, state: 'running' }
|
||||
}
|
||||
|
||||
describe('journal row writer read-only latch', () => {
|
||||
let root: string
|
||||
let readOnly = false
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-'))
|
||||
readOnly = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('enforces the lifecycle append rate and allows a retry after the window', () => {
|
||||
const appendWindowMs = 100
|
||||
const budget = new JournalAppendBudget(SESSION_ID, {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
maxAppendsPerWindow: 1,
|
||||
appendWindowMs
|
||||
})
|
||||
|
||||
budget.assertLifecycle(row(1, 1), 0)
|
||||
expect(() => budget.assertLifecycle(row(2, 1), 0)).toThrow(
|
||||
expect.objectContaining({ code: 'journal_rate_exceeded' })
|
||||
)
|
||||
expect(() => budget.assertLifecycle(row(2, appendWindowMs + 1), 0)).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses lifecycle reservations once aggregate append capacity is saturated', () => {
|
||||
const admission = new JournalLifecycleAdmission(SESSION_ID, 1_000_000, (itemId) => itemId, 2)
|
||||
expect(admission.reserve({ id: 'first', bytes: 1, appendSlots: 1 }, 0)).toBe(true)
|
||||
expect(admission.reserve({ id: 'second', bytes: 1, appendSlots: 1 }, 0)).toBe(true)
|
||||
expect(admission.reserve({ id: 'third', bytes: 1, appendSlots: 1 }, 0)).toBe(false)
|
||||
})
|
||||
|
||||
function writerHarness(
|
||||
overrides: {
|
||||
limits?: typeof DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
physicalBytes?: number
|
||||
appendRows?: (journalDir: string, rows: readonly JournalRow[]) => Promise<void>
|
||||
commit?: (row: JournalRow, physicalBytes: number) => void
|
||||
} = {}
|
||||
) {
|
||||
const limits = overrides.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
const lifecycleAdmission = new JournalLifecycleAdmission(
|
||||
SESSION_ID,
|
||||
limits.maxSessionBytes,
|
||||
(itemId) => itemId
|
||||
)
|
||||
let physicalBytes = overrides.physicalBytes ?? 0
|
||||
let nextSequence = 1
|
||||
const committedRows: JournalRow[] = []
|
||||
const writer = new JournalRowWriter({
|
||||
journalDir: root,
|
||||
sessionId: SESSION_ID,
|
||||
budget: new JournalAppendBudget(SESSION_ID, limits),
|
||||
lifecycleAdmission,
|
||||
autoCompact: false,
|
||||
compaction: { minTailRows: 0, retainTailMs: 0 },
|
||||
now: () => 1,
|
||||
serialize: (run) => run(),
|
||||
readOnly: () => readOnly,
|
||||
setReadOnly: (value) => {
|
||||
readOnly = value
|
||||
},
|
||||
physicalBytes: () => physicalBytes,
|
||||
highestFence: () => 0,
|
||||
nextSequence: () => nextSequence,
|
||||
tailRows: () => committedRows,
|
||||
referencedBlobDigests: () => new Set(),
|
||||
compact: async () => undefined,
|
||||
commit: (row, nextPhysicalBytes) => {
|
||||
overrides.commit?.(row, nextPhysicalBytes)
|
||||
committedRows.push(row)
|
||||
physicalBytes = nextPhysicalBytes
|
||||
nextSequence = row.seq + 1
|
||||
},
|
||||
...(overrides.appendRows ? { appendRows: overrides.appendRows } : {})
|
||||
})
|
||||
return { writer, lifecycleAdmission, committedRows }
|
||||
}
|
||||
|
||||
it('latches read-only when a post-append failure makes durability ambiguous', async () => {
|
||||
let committed = false
|
||||
const writer = new JournalRowWriter({
|
||||
journalDir: root,
|
||||
sessionId: 'session-1',
|
||||
budget: new JournalAppendBudget('session-1', DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
lifecycleAdmission: new JournalLifecycleAdmission(
|
||||
'session-1',
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
|
||||
(itemId) => itemId
|
||||
),
|
||||
autoCompact: false,
|
||||
compaction: { minTailRows: 0, retainTailMs: 0 },
|
||||
now: () => 1,
|
||||
serialize: (run) => run(),
|
||||
readOnly: () => readOnly,
|
||||
setReadOnly: (value) => {
|
||||
readOnly = value
|
||||
},
|
||||
physicalBytes: () => 0,
|
||||
highestFence: () => 0,
|
||||
nextSequence: () => 1,
|
||||
tailRows: () => [],
|
||||
referencedBlobDigests: () => new Set(),
|
||||
compact: async () => undefined,
|
||||
commit: () => {
|
||||
committed = true
|
||||
},
|
||||
appendRows: async (journalDir, rows) => {
|
||||
await appendJournalRows(journalDir, rows)
|
||||
throw new Error('fsync failed after append')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(writer.enqueue(row)).rejects.toThrow('fsync failed after append')
|
||||
|
||||
expect(readOnly).toBe(true)
|
||||
expect(committed).toBe(false)
|
||||
await expect(writer.enqueue(row)).rejects.toMatchObject({ code: 'journal_read_only' })
|
||||
})
|
||||
|
||||
it('keeps blobs for a durable row when a post-append crash is reported', async () => {
|
||||
const payload = 'durable blob payload'.repeat(2_000)
|
||||
const bounded = boundPayload(payload, {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
inlineHeadBytes: 32
|
||||
})
|
||||
const writer = new JournalRowWriter({
|
||||
journalDir: root,
|
||||
sessionId: 'session-1',
|
||||
budget: new JournalAppendBudget('session-1', DEFAULT_JOURNAL_PAYLOAD_LIMITS),
|
||||
lifecycleAdmission: new JournalLifecycleAdmission(
|
||||
'session-1',
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
|
||||
(itemId) => itemId
|
||||
),
|
||||
autoCompact: false,
|
||||
compaction: { minTailRows: 0, retainTailMs: 0 },
|
||||
now: () => 1,
|
||||
serialize: (run) => run(),
|
||||
readOnly: () => readOnly,
|
||||
setReadOnly: (value) => {
|
||||
readOnly = value
|
||||
},
|
||||
physicalBytes: () => 0,
|
||||
highestFence: () => 0,
|
||||
nextSequence: () => 1,
|
||||
tailRows: () => [],
|
||||
referencedBlobDigests: () => new Set(),
|
||||
compact: async () => undefined,
|
||||
commit: () => undefined,
|
||||
appendRows: async (journalDir, rows) => {
|
||||
await appendJournalRows(journalDir, rows)
|
||||
throw new Error('crash after row append')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
writer.enqueue(
|
||||
(seq, ts) => rowWithBlob(seq, ts, bounded),
|
||||
[{ digest: bounded.digest, payload }]
|
||||
)
|
||||
).rejects.toThrow('crash after row append')
|
||||
|
||||
expect(readOnly).toBe(true)
|
||||
await expect(writer.enqueue(row)).rejects.toMatchObject({ code: 'journal_read_only' })
|
||||
expect(await readJournalBlob(root, bounded.digest)).toBe(payload)
|
||||
const reopened = await loadJournal(root, 'session-1')
|
||||
const item = reopened?.state.items.get('item-with-blob')
|
||||
expect(item?.body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
output: { digest: bounded.digest, truncated: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leak a lifecycle reservation after budget refusal', async () => {
|
||||
const probe = runningToolRow(1, 1)
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
maxSessionBytes: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES + journalRowByteLength(probe) - 1
|
||||
}
|
||||
const { writer, lifecycleAdmission, committedRows } = writerHarness({ limits })
|
||||
|
||||
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toMatchObject({
|
||||
code: 'journal_bound_exceeded'
|
||||
})
|
||||
|
||||
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
|
||||
await expect(writer.enqueue(row)).resolves.toMatchObject({ kind: 'item', itemId: 'item-1' })
|
||||
expect(
|
||||
committedRows.map((entry) => (entry.kind === 'item' ? entry.itemId : 'non-item'))
|
||||
).toEqual(['item-1'])
|
||||
})
|
||||
|
||||
it('preflights existing durable-write temps before creating a blob or row', async () => {
|
||||
const tempBytes = 512
|
||||
const tempPath = join(root, 'log.jsonl.existing-write.tmp')
|
||||
await writeFile(tempPath, 't'.repeat(tempBytes), 'utf8')
|
||||
const probe = row(1, 1)
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
maxSessionBytes: tempBytes + journalRowByteLength(probe) - 1
|
||||
}
|
||||
const { writer, committedRows } = writerHarness({ limits })
|
||||
|
||||
await expect(writer.enqueue((seq, ts) => row(seq, ts))).rejects.toMatchObject({
|
||||
code: 'journal_bound_exceeded'
|
||||
})
|
||||
expect(committedRows).toHaveLength(0)
|
||||
expect(await readJournalBlob(root, 'a'.repeat(64))).toBeNull()
|
||||
})
|
||||
|
||||
it('does not leak a lifecycle reservation after blob lookup failure', async () => {
|
||||
const { writer, lifecycleAdmission } = writerHarness()
|
||||
const digest = 'a'.repeat(64)
|
||||
await writeFile(join(root, 'blobs'), 'not a directory', 'utf8')
|
||||
|
||||
await expect(
|
||||
writer.enqueue((seq, ts) => runningToolRow(seq, ts), [{ digest, payload: 'payload' }])
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
|
||||
await rm(join(root, 'blobs'), { force: true })
|
||||
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).resolves.toMatchObject({
|
||||
kind: 'item',
|
||||
itemId: 'running-tool'
|
||||
})
|
||||
expect(lifecycleAdmission.state).toEqual({
|
||||
reservedBytes: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES,
|
||||
reservedAppendSlots: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back ordinary append-rate reservation after blob preflight failure', async () => {
|
||||
const limits = {
|
||||
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
|
||||
maxAppendsPerWindow: 1,
|
||||
appendWindowMs: 100
|
||||
}
|
||||
const { writer, committedRows } = writerHarness({ limits })
|
||||
const payload = 'retryable blob payload'.repeat(100)
|
||||
const bounded = boundPayload(payload, limits)
|
||||
await writeFile(join(root, 'blobs'), 'not a directory', 'utf8')
|
||||
|
||||
await expect(
|
||||
writer.enqueue(
|
||||
(seq, ts) => rowWithBlob(seq, ts, bounded),
|
||||
[{ digest: bounded.digest, payload }]
|
||||
)
|
||||
).rejects.toThrow()
|
||||
|
||||
await rm(join(root, 'blobs'), { force: true })
|
||||
await expect(
|
||||
writer.enqueue(
|
||||
(seq, ts) => rowWithBlob(seq, ts, bounded),
|
||||
[{ digest: bounded.digest, payload }]
|
||||
)
|
||||
).resolves.toMatchObject({ kind: 'item', itemId: 'item-with-blob' })
|
||||
expect(committedRows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not leak a lifecycle reservation after durable append failure', async () => {
|
||||
const { writer, lifecycleAdmission } = writerHarness({
|
||||
appendRows: async () => {
|
||||
throw new Error('append failed before a durable row existed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toThrow(
|
||||
'append failed before a durable row existed'
|
||||
)
|
||||
|
||||
expect(readOnly).toBe(true)
|
||||
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
|
||||
})
|
||||
|
||||
it('does not leak a lifecycle reservation after reducer commit failure', async () => {
|
||||
const { writer, lifecycleAdmission } = writerHarness({
|
||||
commit: () => {
|
||||
throw new Error('commit failed after durable append')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toThrow(
|
||||
'commit failed after durable append'
|
||||
)
|
||||
|
||||
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
// The write path's transaction.
|
||||
//
|
||||
// A transaction either commits or does not, so the old "a post-append failure
|
||||
// makes durability ambiguous" latch has nothing left to latch on: the case that
|
||||
// used to assert the latch asserts the rollback instead.
|
||||
|
||||
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_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
|
||||
import { openJournalDatabase, type OpenJournalDatabase } from './journal-database'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import {
|
||||
insertJournalRow,
|
||||
readJournalEpochRows,
|
||||
upsertJournalSessionRow
|
||||
} from './journal-row-table'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { JournalRowWriter } from './journal-row-writer'
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
const EPOCH = 'epoch-1'
|
||||
|
||||
function row(seq: number, ts: number): JournalRow {
|
||||
return {
|
||||
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
|
||||
epoch: EPOCH,
|
||||
seq,
|
||||
fence: 0,
|
||||
ts,
|
||||
kind: 'item',
|
||||
itemId: 'item-1',
|
||||
revision: 1,
|
||||
body: { kind: 'status', text: 'plain append' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('journal row writer', () => {
|
||||
let root: string
|
||||
let database: OpenJournalDatabase
|
||||
let readOnly = false
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-'))
|
||||
database = openJournalDatabase(journalDatabaseFile(root))
|
||||
upsertJournalSessionRow(database.db, SESSION_ID, EPOCH, 1)
|
||||
readOnly = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
database.db.close()
|
||||
} catch {
|
||||
// Already closed by the case.
|
||||
}
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writerHarness() {
|
||||
const committedRows: JournalRow[] = []
|
||||
let sequence = 1
|
||||
const writer = new JournalRowWriter({
|
||||
sessionId: SESSION_ID,
|
||||
now: () => 1,
|
||||
serialize: (run) => run(),
|
||||
database: () => database,
|
||||
readOnly: () => readOnly,
|
||||
highestFence: () => 0,
|
||||
nextSequence: () => sequence,
|
||||
commit: (committed) => {
|
||||
committedRows.push(committed)
|
||||
sequence = committed.seq + 1
|
||||
}
|
||||
})
|
||||
return { writer, committedRows }
|
||||
}
|
||||
|
||||
it('rolls the transaction back and sets no latch when the insert fails', async () => {
|
||||
const { writer, committedRows } = writerHarness()
|
||||
// A row already occupies sequence 1, so the insert violates the primary key.
|
||||
insertJournalRow(database.db, SESSION_ID, row(1, 1))
|
||||
|
||||
await expect(writer.enqueue(row)).rejects.toThrow()
|
||||
|
||||
expect(readOnly).toBe(false)
|
||||
expect(committedRows).toHaveLength(0)
|
||||
expect(readJournalEpochRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1)
|
||||
// Still writable: there is no ambiguity for a latch to protect against.
|
||||
await expect(writer.enqueue((seq, ts) => row(seq + 1, ts))).resolves.toMatchObject({
|
||||
kind: 'item'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,188 +1,42 @@
|
||||
import {
|
||||
budgetPressurePolicy,
|
||||
journalTailCanShedRows,
|
||||
journalTailIsReadyToCompact,
|
||||
type JournalCompactionPolicy
|
||||
} from './journal-compaction'
|
||||
import { journalBlobFileSize, putJournalBlob, removeJournalBlob } from './journal-blob-store'
|
||||
import { appendJournalRows } from './journal-log-file'
|
||||
import { blobDigestsInBody } from './journal-reducer'
|
||||
import { journalDirectoryBytes } from './journal-physical-quota'
|
||||
import type { JournalLifecycleAdmission } from './journal-lifecycle-admission'
|
||||
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
|
||||
import {
|
||||
AgentSessionJournalError,
|
||||
assertJournalFence,
|
||||
assertJournalWritable,
|
||||
type JournalAppendBudget
|
||||
} from './journal-write-guards'
|
||||
|
||||
type JournalBlob = { digest: string; payload: string }
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { assertJournalFence, assertJournalWritable } from './journal-write-guards'
|
||||
|
||||
export type JournalRowWriterDeps = {
|
||||
journalDir: string
|
||||
sessionId: string
|
||||
budget: JournalAppendBudget
|
||||
lifecycleAdmission: JournalLifecycleAdmission
|
||||
autoCompact: boolean
|
||||
compaction: JournalCompactionPolicy
|
||||
now: () => number
|
||||
serialize: <T>(run: () => Promise<T>) => Promise<T>
|
||||
database: () => { db: Database.Database }
|
||||
readOnly: () => boolean
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
physicalBytes: () => number
|
||||
highestFence: () => number
|
||||
nextSequence: () => number
|
||||
tailRows: () => readonly JournalRow[]
|
||||
referencedBlobDigests: () => ReadonlySet<string>
|
||||
compact: (now: number, policy: JournalCompactionPolicy) => Promise<void>
|
||||
commit: (row: JournalRow, physicalBytes: number) => void
|
||||
appendRows?: (journalDir: string, rows: readonly JournalRow[]) => Promise<void>
|
||||
commit: (row: JournalRow) => void
|
||||
}
|
||||
|
||||
export class JournalRowWriter {
|
||||
constructor(private readonly deps: JournalRowWriterDeps) {}
|
||||
|
||||
enqueue(
|
||||
build: (seq: number, ts: number) => JournalRow,
|
||||
blobs: readonly JournalBlob[] = []
|
||||
): Promise<JournalRow> {
|
||||
enqueue(build: (seq: number, ts: number) => JournalRow): Promise<JournalRow> {
|
||||
return this.deps.serialize(async () => {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
|
||||
const ts = this.deps.now()
|
||||
const row = build(this.deps.nextSequence(), ts)
|
||||
const row = build(this.deps.nextSequence(), this.deps.now())
|
||||
assertJournalFence(row.fence, this.deps.highestFence())
|
||||
// The in-memory counter is an optimization, not the quota source of
|
||||
// truth: a prior crash may have left a durable-write temp beside the
|
||||
// finals, and a concurrent/retried opener may have materialized files
|
||||
// after the last commit callback. Recount before any speculative write
|
||||
// so the peak check includes those bytes.
|
||||
let physicalBytes = Math.max(
|
||||
this.deps.physicalBytes(),
|
||||
await journalDirectoryBytes(this.deps.journalDir)
|
||||
)
|
||||
const admission = this.deps.lifecycleAdmission.prepare(row, physicalBytes)
|
||||
const newBlobs = await uniqueNewBlobs(this.deps.journalDir, blobs)
|
||||
const blobBytes = newBlobs.reduce(
|
||||
(total, blob) => total + Buffer.byteLength(blob.payload, 'utf8'),
|
||||
0
|
||||
)
|
||||
const budgetCompaction = budgetPressurePolicy(this.deps.compaction)
|
||||
let effectiveSize = physicalBytes + blobBytes + admission.protectedBytes
|
||||
if (
|
||||
this.deps.autoCompact &&
|
||||
this.deps.budget.wouldExceedSize(row, effectiveSize) &&
|
||||
journalTailCanShedRows(this.deps.tailRows(), budgetCompaction, ts)
|
||||
) {
|
||||
await this.deps.compact(ts, budgetCompaction)
|
||||
physicalBytes = this.deps.physicalBytes()
|
||||
effectiveSize = physicalBytes + blobBytes + admission.protectedBytes
|
||||
}
|
||||
const lifecycleRateCheckpoint = admission.lifecycleCovered
|
||||
? this.deps.budget.checkpoint()
|
||||
: null
|
||||
const appendRateCheckpoint = this.deps.budget.checkpoint()
|
||||
let committed = false
|
||||
let appendMayHaveLanded = false
|
||||
const { db } = this.deps.database()
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (admission.lifecycleCovered) {
|
||||
this.deps.budget.assertReservedLifecycle(row, effectiveSize)
|
||||
} else {
|
||||
this.deps.budget.assert(row, ts, effectiveSize)
|
||||
}
|
||||
const appendedBytes = blobBytes + journalRowByteLength(row)
|
||||
if (
|
||||
physicalBytes + appendedBytes >
|
||||
this.deps.budget.maxSessionBytes - admission.protectedBytes
|
||||
) {
|
||||
throw new AgentSessionJournalError(
|
||||
'journal_bound_exceeded',
|
||||
`agent-session journal for ${this.deps.sessionId} reached its ${this.deps.budget.maxSessionBytes}-byte physical bound`
|
||||
)
|
||||
}
|
||||
await this.commitFiles(row, newBlobs, () => {
|
||||
appendMayHaveLanded = true
|
||||
})
|
||||
physicalBytes += appendedBytes
|
||||
this.deps.commit(row, physicalBytes)
|
||||
this.deps.lifecycleAdmission.commit(admission)
|
||||
committed = true
|
||||
insertJournalRow(db, this.deps.sessionId, row)
|
||||
upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts)
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
if (!committed && lifecycleRateCheckpoint) {
|
||||
this.deps.budget.restore(lifecycleRateCheckpoint)
|
||||
}
|
||||
if (!committed && !appendMayHaveLanded) {
|
||||
this.deps.budget.restore(appendRateCheckpoint)
|
||||
}
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
this.deps.autoCompact &&
|
||||
journalTailIsReadyToCompact(this.deps.tailRows(), this.deps.compaction, ts)
|
||||
) {
|
||||
await this.deps.compact(ts, this.deps.compaction)
|
||||
}
|
||||
// COMMIT landed, so the row is durable: adopt it before anything that can
|
||||
// fail. Rejecting here instead would leave the next append reusing a
|
||||
// sequence the table already holds.
|
||||
this.deps.commit(row)
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
private async commitFiles(
|
||||
row: JournalRow,
|
||||
blobs: readonly JournalBlob[],
|
||||
markAppendLanded: () => void
|
||||
): Promise<void> {
|
||||
const persisted: string[] = []
|
||||
let appendMayHaveLanded = false
|
||||
try {
|
||||
for (const blob of blobs) {
|
||||
await putJournalBlob(this.deps.journalDir, blob.digest, blob.payload)
|
||||
persisted.push(blob.digest)
|
||||
}
|
||||
appendMayHaveLanded = true
|
||||
markAppendLanded()
|
||||
await (this.deps.appendRows ?? appendJournalRows)(this.deps.journalDir, [row])
|
||||
} catch (error) {
|
||||
if (appendMayHaveLanded) {
|
||||
this.deps.setReadOnly(true)
|
||||
throw error
|
||||
}
|
||||
const retained = this.referencedBlobDigestsIncludingTail()
|
||||
for (const digest of persisted) {
|
||||
if (!retained.has(digest)) {
|
||||
await removeJournalBlob(this.deps.journalDir, digest)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private referencedBlobDigestsIncludingTail(): Set<string> {
|
||||
const retained = new Set(this.deps.referencedBlobDigests())
|
||||
for (const row of this.deps.tailRows()) {
|
||||
if (row.kind === 'item') {
|
||||
blobDigestsInBody(row.body, retained)
|
||||
} else if (row.kind === 'lifecycle-batch') {
|
||||
for (const mutation of row.mutations) {
|
||||
if (mutation.kind === 'item') {
|
||||
blobDigestsInBody(mutation.body, retained)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return retained
|
||||
}
|
||||
}
|
||||
|
||||
async function uniqueNewBlobs(
|
||||
journalDir: string,
|
||||
blobs: readonly JournalBlob[]
|
||||
): Promise<JournalBlob[]> {
|
||||
const unique = new Map(blobs.map((blob) => [blob.digest, blob]))
|
||||
const result: JournalBlob[] = []
|
||||
for (const blob of unique.values()) {
|
||||
if ((await journalBlobFileSize(journalDir, blob.digest)) === null) {
|
||||
result.push(blob)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// `close()`: enqueue-time admission, and the fulfilled/rejected split.
|
||||
//
|
||||
// The two failures this file exists to prevent: an append enqueued in the same
|
||||
// turn as `close()` being rejected AFTER the queue accepted it, and a rejected
|
||||
// close leaving a connection live but permanently unreachable through the API.
|
||||
|
||||
import { access, 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 {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: 'thread-1' }
|
||||
}
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
function body(value: string): AgentJournalItemBody {
|
||||
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
|
||||
}
|
||||
|
||||
function openJournal(): Promise<AgentSessionJournal> {
|
||||
return journals.open({ identity: IDENTITY, journalDir: root })
|
||||
}
|
||||
|
||||
/** Replaces the store's own release step, which is the only step that can
|
||||
* reject the attempt in production. */
|
||||
function injectReleaseFailure(journal: AgentSessionJournal): {
|
||||
calls: () => number
|
||||
stopFailing: () => void
|
||||
} {
|
||||
const internals = journal as unknown as { database: { db: { close: () => void } } }
|
||||
const release = internals.database.db.close.bind(internals.database.db)
|
||||
let calls = 0
|
||||
let failing = true
|
||||
internals.database.db.close = () => {
|
||||
calls += 1
|
||||
if (failing) {
|
||||
throw new Error('injected release failure')
|
||||
}
|
||||
release()
|
||||
}
|
||||
return { calls: () => calls, stopFailing: () => (failing = false) }
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
return access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-close-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('closed-state admission happens at enqueue', () => {
|
||||
it('completes a write enqueued in the same turn as the close', async () => {
|
||||
const journal = await openJournal()
|
||||
const append = journal.appendItem(item(1), body('before'), { fence: 1 })
|
||||
const closed = journal.close()
|
||||
|
||||
await expect(append).resolves.toBeDefined()
|
||||
await expect(closed).resolves.toBeUndefined()
|
||||
const reopened = await openJournal()
|
||||
expect(reopened.snapshot().items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses a write offered while the close is still in flight, without queueing it', async () => {
|
||||
const journal = await openJournal()
|
||||
const closing = journal.close()
|
||||
const refused = journal.appendItem(item(1), body('during'), { fence: 1 })
|
||||
|
||||
// The rejection is available before the close step has run: it never joined
|
||||
// the queue, so nothing is ever chained behind a close.
|
||||
await expect(refused).rejects.toMatchObject({ code: 'journal_closed' })
|
||||
await expect(closing).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses every write entry point after the close has settled', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.close()
|
||||
const settle = (attempt: Promise<unknown>): Promise<unknown> =>
|
||||
attempt.then(
|
||||
() => new Error('resolved instead of refusing'),
|
||||
(error: unknown) => error
|
||||
)
|
||||
const refusals = [
|
||||
settle(journal.appendItem(item(1), body('after'), { fence: 1 })),
|
||||
settle(journal.appendTombstone(item(3), { fence: 1 })),
|
||||
settle(
|
||||
journal.appendSubmission({
|
||||
clientMessageId: 'cm_1',
|
||||
payloadFingerprint: 'f',
|
||||
body: { kind: 'message', role: 'user', blocks: [] },
|
||||
fence: 1
|
||||
})
|
||||
),
|
||||
settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })),
|
||||
settle(
|
||||
journal.appendLifecycleBatch({
|
||||
settlementId: 'settle',
|
||||
fence: 1,
|
||||
mutations: [{ kind: 'item', identity: item(4), body: body('x') }]
|
||||
})
|
||||
),
|
||||
settle(journal.rollEpoch('handle_forked', 1)),
|
||||
settle(journal.replaceEpochItems('handle_forked', 1, []))
|
||||
]
|
||||
for (const refusal of refusals) {
|
||||
expect(await refusal).toMatchObject({ code: 'journal_closed' })
|
||||
}
|
||||
})
|
||||
|
||||
// The property part 1 rests on: every entry point reaches the gate in the
|
||||
// caller's own turn, so a refusal never advances the queue.
|
||||
it('rejects without waiting for the queue to advance', async () => {
|
||||
const journal = await openJournal()
|
||||
const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 })
|
||||
const closing = journal.close()
|
||||
|
||||
// Settles while the admitted append is still running: it reached the gate in
|
||||
// the caller's own turn and never joined the queue behind it.
|
||||
await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({
|
||||
code: 'journal_closed'
|
||||
})
|
||||
await expect(inFlight).resolves.toBeDefined()
|
||||
await expect(closing).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves a second close as a no-op without running the routine again', async () => {
|
||||
const journal = await openJournal()
|
||||
const injected = injectReleaseFailure(journal)
|
||||
injected.stopFailing()
|
||||
await journal.close()
|
||||
expect(injected.calls()).toBe(1)
|
||||
|
||||
await expect(journal.close()).resolves.toBeUndefined()
|
||||
expect(injected.calls()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a rejected close is a real retry', () => {
|
||||
it('retries the release, releases the handle, and then goes terminal', async () => {
|
||||
const journal = await openJournal()
|
||||
await journal.appendItem(item(1), body('durable'), { fence: 1 })
|
||||
const injected = injectReleaseFailure(journal)
|
||||
|
||||
await expect(journal.close()).rejects.toThrow('injected release failure')
|
||||
expect(injected.calls()).toBe(1)
|
||||
|
||||
// Write-closed anyway: retry exists to release the OS handle, never to
|
||||
// resurrect the store.
|
||||
await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({
|
||||
code: 'journal_closed'
|
||||
})
|
||||
|
||||
injected.stopFailing()
|
||||
await expect(journal.close()).resolves.toBeUndefined()
|
||||
// The retry RE-ENTERED the release. A completion flag on it would skip the
|
||||
// one step that had not succeeded, and the handle would never be released.
|
||||
expect(injected.calls()).toBe(2)
|
||||
const dbPath = journalDatabaseFile(root)
|
||||
expect(await exists(`${dbPath}-wal`)).toBe(false)
|
||||
expect(await exists(`${dbPath}-shm`)).toBe(false)
|
||||
|
||||
// Fulfilment is terminal: a third call must not issue a second db.close(),
|
||||
// which `node:sqlite` answers with ERR_INVALID_STATE.
|
||||
await expect(journal.close()).resolves.toBeUndefined()
|
||||
expect(injected.calls()).toBe(2)
|
||||
})
|
||||
|
||||
it('hands concurrent callers the same outcome', async () => {
|
||||
const journal = await openJournal()
|
||||
const injected = injectReleaseFailure(journal)
|
||||
const first = journal.close()
|
||||
const second = journal.close()
|
||||
await expect(first).rejects.toThrow('injected release failure')
|
||||
await expect(second).rejects.toThrow('injected release failure')
|
||||
expect(injected.calls()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// The two rejected readings of the contract, as models, because a green run on
|
||||
// the real store proves nothing about what the alternatives would have done.
|
||||
describe('negative controls', () => {
|
||||
class UnconditionalNoOpClose {
|
||||
calls = 0
|
||||
private called = false
|
||||
constructor(private readonly release: () => void) {}
|
||||
async close(): Promise<void> {
|
||||
if (this.called) {
|
||||
return
|
||||
}
|
||||
this.called = true
|
||||
this.calls += 1
|
||||
this.release()
|
||||
}
|
||||
}
|
||||
|
||||
class AlwaysReentrantClose {
|
||||
calls = 0
|
||||
async close(release: () => void): Promise<void> {
|
||||
this.calls += 1
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
it('an unconditional second-call no-op leaves a failed close unreleasable', async () => {
|
||||
let failing = true
|
||||
let released = false
|
||||
const model = new UnconditionalNoOpClose(() => {
|
||||
if (failing) {
|
||||
throw new Error('injected release failure')
|
||||
}
|
||||
released = true
|
||||
})
|
||||
await expect(model.close()).rejects.toThrow('injected release failure')
|
||||
failing = false
|
||||
await expect(model.close()).resolves.toBeUndefined()
|
||||
// Fulfilled without the routine running: the handle is still held.
|
||||
expect(model.calls).toBe(1)
|
||||
expect(released).toBe(false)
|
||||
})
|
||||
|
||||
it('an always-reentrant close issues the second db.close() that throws', async () => {
|
||||
let open = true
|
||||
const release = (): void => {
|
||||
if (!open) {
|
||||
throw new Error('ERR_INVALID_STATE: database is not open')
|
||||
}
|
||||
open = false
|
||||
}
|
||||
const model = new AlwaysReentrantClose()
|
||||
await model.close(release)
|
||||
await expect(model.close(release)).rejects.toThrow('database is not open')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// Releasing the one SQLite connection a store holds for its lifetime.
|
||||
//
|
||||
// The contract, in five parts:
|
||||
//
|
||||
// 1. Closed-state admission happens at ENQUEUE, once, and is permanent. The
|
||||
// flag lives on the store's write gate; it is never cleared, not by a
|
||||
// rejection and not by a retry. A store that failed to close is still a
|
||||
// store nobody may write to.
|
||||
// 2. The close step shares the write queue and BYPASSES that gate — a close
|
||||
// that consulted the flag it had just set would refuse itself. Same queue
|
||||
// orders the release behind admitted work; separate gate lets it run.
|
||||
// 3. One in-flight attempt, shared: concurrent callers get the same outcome.
|
||||
// 4. Fulfilled is TERMINAL; rejected is not. A later call after fulfilment is a
|
||||
// genuine no-op — `DatabaseSync.close()` throws `ERR_INVALID_STATE` on a
|
||||
// second call, so re-entering after success is the bug, not the fix.
|
||||
// 5. The release is deliberately unguarded. There is no way to ask whether a
|
||||
// `db.close()` that threw released the handle first, and guarding the step
|
||||
// would skip it on retry — guaranteeing a permanent leak in exactly the case
|
||||
// where it did not release.
|
||||
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
|
||||
/**
|
||||
* The write queue and its closed gate. Admission is checked at ENQUEUE and is
|
||||
* permanent; the close step reaches the same queue through `serializePastGate`,
|
||||
* because a close that consulted the flag it had just set would refuse itself.
|
||||
*/
|
||||
export class JournalWriteQueue {
|
||||
private writes: Promise<unknown> = Promise.resolve()
|
||||
private closed = false
|
||||
|
||||
constructor(private readonly sessionId: string) {}
|
||||
|
||||
markClosed(): void {
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
serialize<T>(run: () => Promise<T>): Promise<T> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(
|
||||
new AgentSessionJournalError(
|
||||
'journal_closed',
|
||||
`agent-session journal for ${this.sessionId} is closed`
|
||||
)
|
||||
)
|
||||
}
|
||||
return this.serializePastGate(run)
|
||||
}
|
||||
|
||||
serializePastGate<T>(run: () => Promise<T>): Promise<T> {
|
||||
const started = this.writes.then(run)
|
||||
this.writes = started.catch(() => undefined)
|
||||
return started
|
||||
}
|
||||
}
|
||||
|
||||
export class JournalConnectionCloser {
|
||||
private released = false
|
||||
private inFlight: Promise<void> | null = null
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
connection: () => Database.Database | null
|
||||
/** Chains onto the store's write queue past the closed gate. */
|
||||
enqueue: (run: () => Promise<void>) => Promise<void>
|
||||
}
|
||||
) {}
|
||||
|
||||
get isReleased(): boolean {
|
||||
return this.released
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this.released) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (this.inFlight) {
|
||||
return this.inFlight
|
||||
}
|
||||
const attempt = this.deps
|
||||
.enqueue(() => this.release())
|
||||
.then(
|
||||
() => {
|
||||
this.released = true
|
||||
this.inFlight = null
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.inFlight = null
|
||||
throw error
|
||||
}
|
||||
)
|
||||
this.inFlight = attempt
|
||||
return attempt
|
||||
}
|
||||
|
||||
private async release(): Promise<void> {
|
||||
const db = this.deps.connection()
|
||||
if (!db) {
|
||||
return
|
||||
}
|
||||
// SQLite checkpoints and removes the WAL itself when the last connection to
|
||||
// the database closes.
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Wiring for the store's collaborators.
|
||||
//
|
||||
// Split out of the store itself so the class stays a description of the public
|
||||
// surface rather than sixty lines of constructor plumbing.
|
||||
|
||||
import type {
|
||||
AgentJournalCursor,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { OpenJournalDatabase } from './journal-database'
|
||||
import { JournalEpochController } from './journal-epoch-controller'
|
||||
import { JournalItemAppender } from './journal-item-appender'
|
||||
import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import { JournalRowWriter } from './journal-row-writer'
|
||||
import { restoreJournalStore } from './journal-store-restore'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
|
||||
export type JournalStoreHost = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
journalDir: string
|
||||
now: () => number
|
||||
mintEpoch: () => string
|
||||
serialize: <T>(run: () => Promise<T>) => Promise<T>
|
||||
database: () => OpenJournalDatabase
|
||||
state: () => JournalReducerState
|
||||
readOnly: () => boolean
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
cursor: () => AgentJournalCursor
|
||||
adopt: (loaded: JournalLoad) => void
|
||||
commit: (row: JournalRow) => void
|
||||
/** A caller-supplied load, which suppresses replay entirely when present. */
|
||||
loaded: () => JournalLoad | null | undefined
|
||||
malformedRows: () => number
|
||||
setMalformedRows: (count: number) => void
|
||||
journal: () => AgentSessionJournal
|
||||
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
|
||||
}
|
||||
|
||||
export type JournalStoreCollaborators = {
|
||||
rowWriter: JournalRowWriter
|
||||
epochController: JournalEpochController
|
||||
itemAppender: JournalItemAppender
|
||||
lifecycleBatchAppender: JournalLifecycleBatchAppender
|
||||
/** Restores the store's state from disk. Owned here because it needs the same
|
||||
* collaborators the constructor just built. */
|
||||
restore: () => Promise<void>
|
||||
}
|
||||
|
||||
export function createJournalStoreCollaborators(host: JournalStoreHost): JournalStoreCollaborators {
|
||||
const epochController = new JournalEpochController({
|
||||
identity: host.identity,
|
||||
now: host.now,
|
||||
mintEpoch: host.mintEpoch,
|
||||
serialize: host.serialize,
|
||||
database: host.database,
|
||||
readOnly: host.readOnly,
|
||||
setReadOnly: host.setReadOnly,
|
||||
highestFence: () => host.state().highestFence,
|
||||
cursor: host.cursor,
|
||||
adopt: host.adopt
|
||||
})
|
||||
return {
|
||||
epochController,
|
||||
restore: () => restoreJournalStore(host, { epochController }),
|
||||
rowWriter: new JournalRowWriter({
|
||||
sessionId: host.identity.sessionId,
|
||||
now: host.now,
|
||||
serialize: host.serialize,
|
||||
database: host.database,
|
||||
readOnly: host.readOnly,
|
||||
highestFence: () => host.state().highestFence,
|
||||
nextSequence: () => host.state().lastSequence + 1,
|
||||
commit: host.commit
|
||||
}),
|
||||
itemAppender: new JournalItemAppender({
|
||||
state: host.state,
|
||||
enqueue: host.enqueue
|
||||
}),
|
||||
lifecycleBatchAppender: new JournalLifecycleBatchAppender({
|
||||
state: host.state,
|
||||
cursor: host.cursor,
|
||||
enqueue: host.enqueue
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,13 @@ import type {
|
||||
AgentJournalResetReason,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalCompactionPolicy } from './journal-compaction'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import type { JournalPayloadLimits } from './journal-payload-bounds'
|
||||
import type { JournalLifecycleMutationInput } from './journal-row-builders'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
|
||||
export type AgentSessionJournalOptions = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
journalDir: string
|
||||
limits?: JournalPayloadLimits
|
||||
compaction?: JournalCompactionPolicy
|
||||
/** Compact as the tail grows. Defaults on: without it the log never sheds. */
|
||||
autoCompact?: boolean
|
||||
now?: () => number
|
||||
mintEpoch?: () => string
|
||||
/** A caller that already loaded the journal can avoid reading the same files again. */
|
||||
@@ -45,7 +39,6 @@ export type JournalAppendResult = {
|
||||
}
|
||||
|
||||
export type JournalItemAppendOptions = { fence: number; observedAt?: number; recovered?: true }
|
||||
export type JournalBlobInput = { digest: string; payload: string }
|
||||
export type JournalTombstoneInput = { fence: number }
|
||||
|
||||
export type JournalLifecycleBatchInput = {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
|
||||
import { malformedRowsDisclosure, quarantineCorruptSuffix } from './journal-corruption-quarantine'
|
||||
import { ensureJournalDir } from './journal-log-file'
|
||||
import { loadJournal, type JournalLoad } from './journal-open'
|
||||
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
|
||||
|
||||
export async function ensureJournalDir(journalDir: string): Promise<void> {
|
||||
await mkdir(journalDir, { recursive: true })
|
||||
}
|
||||
|
||||
export function journalStoreLoadedFields(loaded: JournalLoad) {
|
||||
return {
|
||||
state: loaded.state,
|
||||
tailRows: loaded.tailRows,
|
||||
compactedThrough: loaded.compactedThrough,
|
||||
sizeBytes: loaded.sizeBytes,
|
||||
readOnly: loaded.readOnly,
|
||||
malformedRows: loaded.malformedRows
|
||||
}
|
||||
@@ -18,60 +16,47 @@ export function journalStoreLoadedFields(loaded: JournalLoad) {
|
||||
|
||||
export async function openJournalStoreState(input: {
|
||||
journalDir: string
|
||||
sessionId: string
|
||||
maxBytes: number
|
||||
loaded: JournalLoad | null | undefined
|
||||
start: () => Promise<void>
|
||||
replay: () => JournalLoad | null
|
||||
/** Drops the rejected suffix and records the rebuild it owes, in ONE
|
||||
* transaction. Corruption is not preserved; replay keeps reporting `corrupt`
|
||||
* until provider history republishes the epoch or the session writes past
|
||||
* `contentFrom`, the first sequence the repair left free. */
|
||||
deleteSuffix: (fromSeq: number, contentFrom: number) => number
|
||||
start: () => void
|
||||
adopt: (loaded: JournalLoad) => void
|
||||
tailRows: () => readonly JournalRow[]
|
||||
snapshot: () => AgentJournalSnapshot
|
||||
rebuildLifecycle: (snapshot: AgentJournalSnapshot, physicalBytes: number) => void
|
||||
/** Republishes an anchor row for an epoch a repair emptied. */
|
||||
publishRepairEpoch: () => void
|
||||
appendDisclosure: (
|
||||
identity: ReturnType<typeof malformedRowsDisclosure>['identity'],
|
||||
body: ReturnType<typeof malformedRowsDisclosure>['body'],
|
||||
identity: JournalRepairDisclosure['identity'],
|
||||
body: JournalRepairDisclosure['body'],
|
||||
fence: number
|
||||
) => Promise<unknown>
|
||||
highestFence: () => number
|
||||
malformedRows: () => number
|
||||
setMalformedRows: (count: number) => void
|
||||
readOnly: () => boolean
|
||||
setPhysicalBytes: (bytes: number) => void
|
||||
}): Promise<void> {
|
||||
await ensureJournalDir(input.journalDir)
|
||||
input.setPhysicalBytes(
|
||||
await assertJournalPhysicalCapacity({
|
||||
journalDir: input.journalDir,
|
||||
sessionId: input.sessionId,
|
||||
maxBytes: input.maxBytes
|
||||
})
|
||||
)
|
||||
const loaded =
|
||||
input.loaded !== undefined
|
||||
? input.loaded
|
||||
: await loadJournal(input.journalDir, input.sessionId, { maxBytes: input.maxBytes })
|
||||
const loaded = input.loaded !== undefined ? input.loaded : input.replay()
|
||||
if (!loaded) {
|
||||
await input.start()
|
||||
input.setPhysicalBytes(await journalDirectoryBytes(input.journalDir))
|
||||
input.start()
|
||||
return
|
||||
}
|
||||
input.adopt(loaded)
|
||||
if (loaded.corrupt && !loaded.readOnly) {
|
||||
await quarantineCorruptSuffix(input.journalDir, input.tailRows(), loaded.quarantineRemainder, {
|
||||
sessionId: input.sessionId,
|
||||
maxBytes: input.maxBytes
|
||||
})
|
||||
if (loaded.truncateFrom !== undefined && !loaded.readOnly) {
|
||||
input.deleteSuffix(loaded.truncateFrom, loaded.state.lastSequence + 1)
|
||||
}
|
||||
let physicalBytes = await journalDirectoryBytes(input.journalDir)
|
||||
input.setPhysicalBytes(physicalBytes)
|
||||
// A future-schema/read-only journal is inspection-only. Its reduced state is
|
||||
// intentionally empty, and rebuilding reservations from it would mutate the
|
||||
// in-memory quota model (and could influence later admission decisions).
|
||||
if (!loaded.readOnly) {
|
||||
input.rebuildLifecycle(input.snapshot(), physicalBytes)
|
||||
// A repair that took every live row leaves the epoch with no anchor. Publish
|
||||
// one before anything can append into it: an ordinary row at sequence 1 would
|
||||
// replay as a clean timeline and hide that the history was never rebuilt.
|
||||
if (!loaded.readOnly && loaded.state.lastSequence === 0) {
|
||||
input.publishRepairEpoch()
|
||||
// The replacement epoch adopts a clean load; what this open's repair did is
|
||||
// still the answer `repair` and the disclosure below owe the caller.
|
||||
input.setMalformedRows(loaded.malformedRows)
|
||||
}
|
||||
if (input.malformedRows() > 0 && !input.readOnly()) {
|
||||
const disclosure = malformedRowsDisclosure(input.malformedRows())
|
||||
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
|
||||
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
|
||||
}
|
||||
physicalBytes = await journalDirectoryBytes(input.journalDir)
|
||||
input.setPhysicalBytes(physicalBytes)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 replay, suffix repair and disclosure, 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.
|
||||
|
||||
import type { JournalEpochController } from './journal-epoch-controller'
|
||||
import { replayJournal } from './journal-open'
|
||||
import type { JournalStoreHost } from './journal-store-collaborators'
|
||||
import { openJournalStoreState } from './journal-store-open'
|
||||
import { deleteJournalRepairedSuffix } from './journal-repair-marker'
|
||||
|
||||
export function restoreJournalStore(
|
||||
host: JournalStoreHost,
|
||||
collaborators: { epochController: JournalEpochController }
|
||||
): Promise<void> {
|
||||
return openJournalStoreState({
|
||||
journalDir: host.journalDir,
|
||||
loaded: host.loaded(),
|
||||
replay: () => {
|
||||
const opened = host.database()
|
||||
return replayJournal(opened.db, opened.readOnly, host.identity.sessionId)
|
||||
},
|
||||
deleteSuffix: (fromSeq, contentFrom) =>
|
||||
deleteJournalRepairedSuffix({
|
||||
db: host.database().db,
|
||||
sessionId: host.identity.sessionId,
|
||||
epoch: host.state().epoch,
|
||||
fromSeq,
|
||||
contentFrom,
|
||||
now: host.now()
|
||||
}),
|
||||
start: () => collaborators.epochController.start('session_created', 0),
|
||||
// `unreconcilable_prefix` is the durable statement that this epoch exists
|
||||
// because a repair emptied one: replay reads it back and keeps asking for
|
||||
// provider history until the timeline is rebuilt or the session writes.
|
||||
publishRepairEpoch: () =>
|
||||
collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence),
|
||||
adopt: host.adopt,
|
||||
appendDisclosure: (identity, body, fence) =>
|
||||
host.journal().appendItem(identity, body, { fence }),
|
||||
highestFence: () => host.state().highestFence,
|
||||
malformedRows: host.malformedRows,
|
||||
setMalformedRows: host.setMalformedRows,
|
||||
readOnly: host.readOnly
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user