mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* refactor(persistence): retire ordinary JSON profile writes Require SQLite for writable profiles and keep import, compatibility export, and recovery in a documented legacy-json boundary. * fix(cli): preserve dynamic profile imports in release output * test(persistence): exercise SQL races and verify packaged CLI imports * test(persistence): consolidate shared fixture imports * test(persistence): close SQLite fixtures before cleanup and await launcher output * test(automations): use SQLite fixtures for dispatch fencing and skip coalescing --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
import { createWorkerMaintenanceFixture } from './persistence/loading-store/profile-state-maintenance-fixture'
|
|
import type { Store } from './persistence/loading-store/store'
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { mkdtempSync, readFileSync, existsSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
import type * as NodeFs from 'node:fs'
|
|
import type * as NodeFsPromises from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
|
|
// Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously,
|
|
// before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park
|
|
// the Electron main thread uninterruptibly — the app stops repainting and Force Quit stops
|
|
// working. The teardown deadline cannot rescue that, because its own timer needs the thread it
|
|
// would have to bound. The fix is to make the quit path awaitable, not to bound it.
|
|
|
|
const testState = { dir: '' }
|
|
|
|
const fsCalls = vi.hoisted(() => {
|
|
const calls = {
|
|
recording: false,
|
|
dirPrefix: '',
|
|
syncCalls: [] as string[],
|
|
/** Set to park every in-scope async fs call — a stalled mount, without stalling the thread. */
|
|
holdAsync: null as Promise<void> | null,
|
|
waitAsync: null as ((fn: string, target: string) => Promise<void> | null) | null,
|
|
reset(): void {
|
|
calls.syncCalls.length = 0
|
|
calls.holdAsync = null
|
|
calls.waitAsync = null
|
|
},
|
|
inScope(target: unknown): target is string {
|
|
return (
|
|
typeof target === 'string' && calls.dirPrefix !== '' && target.startsWith(calls.dirPrefix)
|
|
)
|
|
},
|
|
recordSync(fn: string, target: unknown): void {
|
|
if (calls.recording && calls.inScope(target)) {
|
|
calls.syncCalls.push(`${fn}:${target}`)
|
|
}
|
|
}
|
|
}
|
|
return calls
|
|
})
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof NodeFs>()
|
|
const patched: Record<string, unknown> = { ...actual }
|
|
for (const name of Object.keys(actual)) {
|
|
const original = (actual as unknown as Record<string, unknown>)[name]
|
|
if (!name.endsWith('Sync') || typeof original !== 'function') {
|
|
continue
|
|
}
|
|
const fn = original as (...args: unknown[]) => unknown
|
|
const wrapper = (...args: unknown[]): unknown => {
|
|
fsCalls.recordSync(name, args[0])
|
|
return fn(...args)
|
|
}
|
|
patched[name] = Object.assign(wrapper, fn)
|
|
}
|
|
return { ...patched, default: patched }
|
|
})
|
|
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof NodeFsPromises>()
|
|
const patched: Record<string, unknown> = { ...actual }
|
|
for (const name of [
|
|
'stat',
|
|
'access',
|
|
'rename',
|
|
'copyFile',
|
|
'rm',
|
|
'mkdir',
|
|
'open',
|
|
'readFile',
|
|
'writeFile',
|
|
'link'
|
|
]) {
|
|
const fn = (actual as unknown as Record<string, (...args: unknown[]) => unknown>)[name]
|
|
patched[name] = async (...args: unknown[]): Promise<unknown> => {
|
|
if (fsCalls.inScope(args[0]) && fsCalls.holdAsync) {
|
|
await fsCalls.holdAsync
|
|
}
|
|
if (fsCalls.inScope(args[0]) && typeof args[0] === 'string') {
|
|
await fsCalls.waitAsync?.(name, args[0])
|
|
}
|
|
return fn(...args)
|
|
}
|
|
}
|
|
return { ...patched, default: patched }
|
|
})
|
|
|
|
vi.mock('./ssh/ssh-config-parser', () => ({
|
|
loadUserSshConfig: vi.fn(),
|
|
sshConfigHostsToTargets: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
|
|
|
vi.mock('./telemetry/cohort-classifier', () => ({
|
|
getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 })
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getPath: () => testState.dir },
|
|
safeStorage: {
|
|
isEncryptionAvailable: () => true,
|
|
encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
|
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length)
|
|
}
|
|
}))
|
|
|
|
type TestStatsCollector = {
|
|
onAgentStart(ptyId: string, at: number, repo?: string, worktree?: string): void
|
|
getSummary(): { totalAgentsSpawned: number; totalAgentTimeMs: number }
|
|
flushAsync(): Promise<void>
|
|
}
|
|
|
|
const profiles = new Map<string, Awaited<ReturnType<typeof createWorkerMaintenanceFixture>>>()
|
|
const stores: Store[] = []
|
|
const statsCollectors: TestStatsCollector[] = []
|
|
const releases: (() => void)[] = []
|
|
|
|
async function createStore(dir: string): Promise<Store> {
|
|
testState.dir = dir
|
|
const fixture = await createWorkerMaintenanceFixture({ directory: dir, profileId: 'quit-test' })
|
|
profiles.set(dir, fixture)
|
|
stores.push(fixture.store)
|
|
fixture.store.updateUI({ activeView: 'terminal' })
|
|
await fixture.store.flushPendingOrThrowAsync()
|
|
await fixture.authority.drainBackups()
|
|
return fixture.store
|
|
}
|
|
|
|
function readState(dir: string) {
|
|
const profile = profiles.get(dir)
|
|
if (!profile) {
|
|
throw new Error('Missing test profile')
|
|
}
|
|
return profile.readState()
|
|
}
|
|
|
|
function stallFilesystem(): void {
|
|
const gate = Promise.withResolvers<void>()
|
|
releases.push(gate.resolve)
|
|
fsCalls.holdAsync = gate.promise
|
|
}
|
|
|
|
function stallCommit(dir: string) {
|
|
const profile = profiles.get(dir)
|
|
if (!profile) {
|
|
throw new Error('Missing test profile')
|
|
}
|
|
const started = Promise.withResolvers<void>()
|
|
const finish = Promise.withResolvers<void>()
|
|
releases.push(finish.resolve)
|
|
const original = profile.authority.writeCompleteSerializedDomains.bind(profile.authority)
|
|
vi.spyOn(profile.authority, 'writeCompleteSerializedDomains').mockImplementationOnce(
|
|
async (rows) => {
|
|
started.resolve()
|
|
await finish.promise
|
|
await original(rows)
|
|
}
|
|
)
|
|
return { started: started.promise, release: finish.resolve }
|
|
}
|
|
|
|
async function createStatsCollector(dir: string): Promise<TestStatsCollector> {
|
|
testState.dir = dir
|
|
const { StatsCollector, initStatsPath } = await import('./stats/collector')
|
|
initStatsPath()
|
|
const stats = new StatsCollector()
|
|
statsCollectors.push(stats)
|
|
return stats
|
|
}
|
|
|
|
const dataFile = (dir: string): string => join(dir, 'orca-data.json')
|
|
const statsFile = (dir: string): string => join(dir, 'orca-stats.json')
|
|
const activeViewFile = (dir: string): string => join(dir, 'active-view.json')
|
|
|
|
/** Resolves once the macrotask queue turns over — false if the main thread is parked. */
|
|
function eventLoopTurned(): Promise<boolean> {
|
|
return new Promise((resolve) => setTimeout(() => resolve(true), 0))
|
|
}
|
|
|
|
describe('quit-path durable writes never park the main thread', () => {
|
|
const dirs: string[] = []
|
|
|
|
function makeDir(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'orca-quit-path-'))
|
|
dirs.push(dir)
|
|
return dir
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fsCalls.recording = false
|
|
fsCalls.dirPrefix = ''
|
|
fsCalls.reset()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
fsCalls.recording = false
|
|
for (const release of releases.splice(0)) {
|
|
release()
|
|
}
|
|
fsCalls.holdAsync = null
|
|
fsCalls.waitAsync = null
|
|
vi.useRealTimers()
|
|
await Promise.all(stores.splice(0).map((store) => store.freezeWritesAsync()))
|
|
await Promise.all(statsCollectors.splice(0).map((stats) => stats.flushAsync()))
|
|
profiles.clear()
|
|
for (const dir of dirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('store.flushAsync() issues no synchronous fs syscalls', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
store.updateUI({ sidebarWidth: 321 })
|
|
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
await store.flushAsync()
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
expect(readState(dir).ui.sidebarWidth).toBe(321)
|
|
})
|
|
|
|
it('renderer unload staging issues no synchronous fs syscalls', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
const leafId = '11111111-1111-4111-8111-111111111111'
|
|
store.stageWorkspaceSessionBeforeUnload({
|
|
activeRepoId: null,
|
|
activeWorktreeId: null,
|
|
activeTabId: null,
|
|
tabsByWorktree: {
|
|
'remote-repo::/remote': [
|
|
{
|
|
id: 'remote-tab',
|
|
ptyId: 'remote-pty',
|
|
worktreeId: 'remote-repo::/remote',
|
|
title: 'Remote',
|
|
customTitle: null,
|
|
color: null,
|
|
sortOrder: 0,
|
|
createdAt: 1
|
|
}
|
|
]
|
|
},
|
|
terminalLayoutsByTabId: {
|
|
'remote-tab': {
|
|
root: { type: 'leaf', leafId },
|
|
activeLeafId: leafId,
|
|
expandedLeafId: null,
|
|
buffersByLeafId: { [leafId]: 'remote-scrollback' }
|
|
}
|
|
},
|
|
openFilesByWorktree: {}
|
|
})
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
await store.flushAsync()
|
|
const layout = readState(dir).workspaceSession.terminalLayoutsByTabId['remote-tab']
|
|
const ref = layout.scrollbackRefsByLeafId[leafId]
|
|
expect(layout.buffersByLeafId).toBeUndefined()
|
|
expect(readFileSync(join(dir, 'terminal-scrollback', `${ref}.bin`), 'utf-8')).toBe(
|
|
'remote-scrollback'
|
|
)
|
|
})
|
|
|
|
it('store.flushAsync() also moves the active-view and github-cache sidecars off the thread', async () => {
|
|
const dir = makeDir()
|
|
const staleCacheTemp = join(dir, 'orca-github-cache.json.999999.1.orphan.tmp')
|
|
writeFileSync(staleCacheTemp, 'stale', 'utf-8')
|
|
const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000
|
|
utimesSync(staleCacheTemp, staleSeconds, staleSeconds)
|
|
const store = await createStore(dir)
|
|
store.updateUI({ activeView: 'activity' })
|
|
store.setGitHubCache({ pr: {}, issue: {} })
|
|
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
await store.flushAsync()
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('activity')
|
|
expect(existsSync(join(dir, 'orca-github-cache.json'))).toBe(true)
|
|
expect(existsSync(staleCacheTemp)).toBe(false)
|
|
})
|
|
|
|
it('stats.flushAsync() issues no synchronous fs syscalls', async () => {
|
|
const dir = makeDir()
|
|
const stats = await createStatsCollector(dir)
|
|
stats.onAgentStart('pty-1', 1_000, 'repo', 'worktree')
|
|
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
await stats.flushAsync()
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
expect(JSON.parse(readFileSync(statsFile(dir), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1)
|
|
})
|
|
|
|
it('Grok session cleanup issues no synchronous fs syscalls', async () => {
|
|
const dir = makeDir()
|
|
const previousGrokHome = process.env.GROK_HOME
|
|
process.env.GROK_HOME = dir
|
|
const { GrokHookService } = await import('./grok/hook-service')
|
|
const service = new GrokHookService()
|
|
try {
|
|
const configPath = join(dir, 'hooks', 'orca-status.json')
|
|
const configDir = join(dir, 'hooks')
|
|
const { mkdirSync } = await import('node:fs')
|
|
mkdirSync(configDir, { recursive: true })
|
|
writeFileSync(
|
|
configPath,
|
|
`${JSON.stringify({
|
|
hooks: {
|
|
SessionStart: [
|
|
{ hooks: [{ type: 'command', command: '/home/test/.orca/agent-hooks/grok-hook.sh' }] }
|
|
]
|
|
}
|
|
})}\n`
|
|
)
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
await service.removeAsync()
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
expect(existsSync(configPath)).toBe(false)
|
|
} finally {
|
|
fsCalls.recording = false
|
|
if (previousGrokHome === undefined) {
|
|
delete process.env.GROK_HOME
|
|
} else {
|
|
process.env.GROK_HOME = previousGrokHome
|
|
}
|
|
}
|
|
})
|
|
|
|
it('stats.flushAsync() closes out live agents before it returns, not when its write lands', async () => {
|
|
// will-quit calls this before killAllPty(), which skips runtime.onPtyExit(). If the
|
|
// closeout moved behind the await, every agent still running at quit would lose its
|
|
// session time — the whole reason the flush is ordered ahead of the kill.
|
|
const dir = makeDir()
|
|
const stats = await createStatsCollector(dir)
|
|
stats.onAgentStart('pty-1', Date.now() - 4_000)
|
|
|
|
fsCalls.dirPrefix = dir
|
|
stallFilesystem()
|
|
const pending = stats.flushAsync()
|
|
|
|
expect(stats.getSummary().totalAgentTimeMs).toBeGreaterThan(0)
|
|
void pending
|
|
})
|
|
|
|
it('a stalled mount leaves the event loop live instead of parking it', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
store.updateUI({ sidebarWidth: 654 })
|
|
|
|
fsCalls.dirPrefix = dir
|
|
const gate = stallCommit(dir)
|
|
const pending = store.flushAsync()
|
|
await gate.started
|
|
|
|
// The whole point: timers still fire, so the app still repaints and the quit
|
|
// deadline below can still be reached. A sync flush would have blocked here.
|
|
await expect(eventLoopTurned()).resolves.toBe(true)
|
|
void pending
|
|
})
|
|
|
|
it('the quit teardown deadline can now bound a wedged state flush', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
const { settleTeardownWithinDeadline } = await import('./quit-teardown-deadline')
|
|
store.updateUI({ sidebarWidth: 999 })
|
|
|
|
fsCalls.dirPrefix = dir
|
|
const before = readState(dir).ui.sidebarWidth
|
|
const gate = stallCommit(dir)
|
|
const pending = store.flushAsync()
|
|
await gate.started
|
|
|
|
const outstanding = await settleTeardownWithinDeadline(
|
|
[{ name: 'state', promise: pending }],
|
|
25
|
|
)
|
|
|
|
expect(outstanding).toEqual(['state'])
|
|
expect(readState(dir).ui.sidebarWidth).toBe(before)
|
|
gate.release()
|
|
await pending
|
|
expect(readState(dir).ui.sidebarWidth).toBe(999)
|
|
})
|
|
|
|
it('store.flushAsync() drains an in-flight debounced write before writing', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
const gate = stallCommit(dir)
|
|
vi.useFakeTimers()
|
|
store.updateUI({ sidebarWidth: 100 })
|
|
await vi.advanceTimersByTimeAsync(1_000)
|
|
await gate.started
|
|
|
|
store.updateUI({ sidebarWidth: 200 })
|
|
const flushed = store.flushAsync()
|
|
vi.useRealTimers()
|
|
gate.release()
|
|
await flushed
|
|
|
|
expect(readState(dir).ui.sidebarWidth).toBe(200)
|
|
})
|
|
|
|
it('store.flushAsync() is one idempotent final barrier', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
store.updateUI({ sidebarWidth: 777 })
|
|
|
|
const first = store.flushAsync()
|
|
const second = store.flushAsync()
|
|
|
|
expect(second).toBe(first)
|
|
expect(() => store.flushOrThrow()).toThrow('final persistence')
|
|
await first
|
|
expect(readState(dir).ui.sidebarWidth).toBe(777)
|
|
})
|
|
|
|
it('serializes github-cache snapshots and keeps the newest generation', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
let releaseWrite!: () => void
|
|
const writeRelease = new Promise<void>((resolve) => {
|
|
releaseWrite = resolve
|
|
})
|
|
let signalWrite!: () => void
|
|
releases.push(releaseWrite)
|
|
const writeStarted = new Promise<void>((resolve) => {
|
|
signalWrite = resolve
|
|
})
|
|
let held = false
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
fsCalls.waitAsync = (fn, target) => {
|
|
if (held || fn !== 'writeFile' || !target.includes('orca-github-cache.json')) {
|
|
return null
|
|
}
|
|
held = true
|
|
signalWrite()
|
|
return writeRelease
|
|
}
|
|
|
|
store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 1 } }, issue: {} })
|
|
const finalFlush = store.flushAsync()
|
|
await writeStarted
|
|
store.setGitHubCache({ pr: { test: { data: null, fetchedAt: 2 } }, issue: {} })
|
|
|
|
releaseWrite()
|
|
await finalFlush
|
|
fsCalls.recording = false
|
|
|
|
expect(JSON.parse(readFileSync(join(dir, 'orca-github-cache.json'), 'utf-8'))).toEqual({
|
|
pr: { test: { data: null, fetchedAt: 2 } },
|
|
issue: {}
|
|
})
|
|
})
|
|
|
|
it('stats.flushAsync() wins over a debounced write already in flight', async () => {
|
|
const dir = makeDir()
|
|
const stats = await createStatsCollector(dir)
|
|
stats.onAgentStart('pty-a', 1_000)
|
|
stats.onAgentStart('pty-b', 1_000)
|
|
|
|
const inflight = (stats as unknown as { enqueueWrite(): Promise<void> }).enqueueWrite.call(
|
|
stats
|
|
)
|
|
await stats.flushAsync()
|
|
await inflight
|
|
|
|
// Serialized writes plus the generation guard: the flush's fuller state survives.
|
|
const persisted = JSON.parse(readFileSync(statsFile(dir), 'utf-8'))
|
|
expect(persisted.aggregates.totalAgentsSpawned).toBe(2)
|
|
expect(persisted.aggregates.totalAgentTimeMs).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('the quit flush is the last write — later mutations do not schedule another', async () => {
|
|
// A teardown step that touches the store would otherwise arm a debounced write with
|
|
// nothing awaiting it, leaving a rename to race the process exit.
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
vi.useFakeTimers()
|
|
store.updateUI({ sidebarWidth: 10 })
|
|
await store.flushAsync()
|
|
|
|
store.updateUI({ sidebarWidth: 20, activeView: 'tasks' })
|
|
fsCalls.dirPrefix = dir
|
|
fsCalls.recording = true
|
|
await vi.runAllTimersAsync()
|
|
fsCalls.recording = false
|
|
|
|
expect(fsCalls.syncCalls).toEqual([])
|
|
expect(readState(dir).ui.sidebarWidth).toBe(10)
|
|
expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('terminal')
|
|
})
|
|
|
|
it('sweeps orphaned state and stats temps while retaining fresh process temps', async () => {
|
|
const dir = makeDir()
|
|
const stateTemp = `${dataFile(dir)}.999999.1.orphan.tmp`
|
|
const statsTemp = `${statsFile(dir)}.999999.1.orphan.tmp`
|
|
const freshOtherProcessTemp = `${dataFile(dir)}.999998.1.live.tmp`
|
|
writeFileSync(stateTemp, 'stale', 'utf-8')
|
|
writeFileSync(statsTemp, 'stale', 'utf-8')
|
|
writeFileSync(freshOtherProcessTemp, 'in-flight', 'utf-8')
|
|
const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000
|
|
utimesSync(stateTemp, staleSeconds, staleSeconds)
|
|
utimesSync(statsTemp, staleSeconds, staleSeconds)
|
|
|
|
const store = await createStore(dir)
|
|
const stats = await createStatsCollector(dir)
|
|
store.updateUI({ sidebarWidth: 123 })
|
|
stats.onAgentStart('pty-sweep', Date.now())
|
|
await Promise.all([store.flushAsync(), stats.flushAsync()])
|
|
|
|
expect(existsSync(stateTemp)).toBe(false)
|
|
expect(existsSync(dataFile(dir))).toBe(false)
|
|
expect(existsSync(statsTemp)).toBe(false)
|
|
expect(existsSync(freshOtherProcessTemp)).toBe(true)
|
|
})
|
|
|
|
it('store.flushAsync() resolves instead of throwing when the profile mount rejects writes', async () => {
|
|
const dir = makeDir()
|
|
const store = await createStore(dir)
|
|
store.updateUI({ sidebarWidth: 42 })
|
|
const profile = profiles.get(dir)
|
|
if (!profile) {
|
|
throw new Error('Missing test profile')
|
|
}
|
|
vi.spyOn(profile.authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce(
|
|
new Error('profile mount rejected write')
|
|
)
|
|
|
|
// It joins the teardown barrier; a rejection there is noise that must not cancel the quit.
|
|
await expect(store.flushAsync()).resolves.toBeUndefined()
|
|
})
|
|
})
|