Merge remote-tracking branch 'origin/master' into session-query-search

# Conflicts:
#	docs/architecture.i18n.yaml
#	packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence/README.md
This commit is contained in:
Hypatia May
2026-07-23 21:13:29 +08:00
461 changed files with 21288 additions and 6015 deletions
@@ -20,6 +20,15 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader {
return header
}
/** Rewrite only a stored header while preserving every event byte below it. */
async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
const lines = (await readFile(path, 'utf8')).split('\n')
const header = JSON.parse(lines[0] as string) as Record<string, unknown>
update(header)
lines[0] = JSON.stringify(header)
await writeFile(path, lines.join('\n'))
}
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
await promise
@@ -455,6 +464,31 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('rejects a mismatched header before repairing either session log', async () => {
const a = meta('identity-a', '/same')
const b = meta('identity-b', '/same')
await ctx.sessionPersistence.create(a)
await ctx.sessionPersistence.append(a.id, [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
await ctx.sessionPersistence.create(b)
await ctx.sessionPersistence.append(b.id, oneTurnLog())
const aPath = rawLogPath(root, a.cwd, a.id)
const bPath = rawLogPath(root, b.cwd, b.id)
await rewriteHeader(aPath, (header) => { header.id = b.id })
const beforeA = await readFile(aPath)
const beforeB = await readFile(bPath)
await expect(ctx.sessionPersistence.load(a.id))
.rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
expect(await readFile(aPath)).toEqual(beforeA)
expect(await readFile(bPath)).toEqual(beforeB)
})
it('rejects a re-append of an already-stored seq', async () => {
const m = meta('reappend')
await ctx.sessionPersistence.create(m)
@@ -799,6 +833,38 @@ describe('SessionPersistenceJsonl: edge cases', () => {
expect(ids).toContain('big')
})
it('list rejects a header whose cwd does not identify its physical log', async () => {
const m = meta('misplaced', '/stored')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/)
})
it('list rejects a session header whose id cannot name a storage path', async () => {
const bucket = sessionDir(root, undefined)
await mkdir(bucket, { recursive: true })
await writeFile(join(bucket, 'invalid-id.jsonl'), JSON.stringify({
type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0,
}) + '\n')
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
})
it('load and list reject one id materialized in multiple cwd buckets', async () => {
const id = SessionId('duplicate')
for (const cwd of ['/a', '/b']) {
const m = meta(id, cwd)
await mkdir(sessionDir(root, cwd), { recursive: true })
const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
await writeFile(rawLogPath(root, cwd, id), content)
}
await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple cwd buckets/)
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple cwd buckets/)
})
it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
// Session A materializes a log under id "reuse".
const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
@@ -819,18 +885,16 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
})
it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => {
it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => {
// Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
// dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
// the HMR/reload path where onCreated goes through loadLive, not a tracked
// collision).
// the HMR/reload path with no tracked collision state).
await ctx.sessionPersistence.create(meta('x', '/w'))
await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
await ctx.fiber.dispose()
// Backend 2 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id,
// undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead
// of grafting no-cwd events onto a log with mismatched cwd.
// Backend 2 creates a no-cwd session whose id exists only in `/w`. The
// stored cwd check rejects instead of grafting no-cwd events onto that log.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
@@ -838,7 +902,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create(SessionId('x')) // no cwd
}, { inject: ['sessions'] }))
await expect(ctx2.sessions.flush(b)).rejects.toThrow(/already has a persisted log on disk/)
await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/)
// The "/w" log is untouched — no no-cwd events were grafted onto it, and no
// `_no-cwd` log for "x" was created.
@@ -897,21 +961,31 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx2.fiber.dispose()
})
it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
// A durable backend must not collapse a storage fault to "no sessions". Making the root a
// regular file forces ENOTDIR from `readdir`, which must propagate.
it('plugin load rejects an existing root that is not a directory', async () => {
const filePath = join(root, 'not-a-dir')
await writeFile(filePath, 'x')
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })
await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
// A non-ENOENT per-id open error must surface rather than become "not found" and permit false
// live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path.
it('list surfaces a root that becomes unusable after plugin load', async () => {
await rm(root, { recursive: true })
await writeFile(root, 'not a directory')
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
})
it('per-id lookup surfaces non-ENOENT storage errors', async () => {
const blocker = join(root, 'not-a-directory')
await writeFile(blocker, 'x')
const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise<boolean> }
await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
})
it('materialization surfaces a cwd-bucket storage fault', async () => {
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
@@ -920,8 +994,9 @@ describe('SessionPersistenceJsonl: edge cases', () => {
let s!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
appendClosedTurn(s)
}, { inject: ['sessions'] }))
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/ENOTDIR/)
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/)
await ctx2.fiber.dispose()
})
@@ -460,7 +460,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
'',
].join('\n'))
await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/)
await expect((ctx.sessionPersistence as SessionPersistenceJsonl).loadLive(loadHeader.id, loadHeader.cwd))
await expect((ctx.sessionPersistence as SessionPersistenceJsonl).loadStored(loadHeader.id))
.rejects.toThrow(/uses \.jsonl/)
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/)
})