| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- /**
- * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
- * count/interval throttling between them, fail-soft durability (a failed
- * write logs and stays stale, never throws into the event path), and the
- * cached listing read. The durable medium is one `projection_cache.json`
- * per session under the cache's own configured root
- * (`<root>/<session-id>/projection_cache.json`); the cache never consults
- * the persistence layer.
- */
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { Context } from '@deepseek-ai/cordis'
- import { z } from 'zod'
- import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
- import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
- import SessionProjectionCache from '../src/index.ts'
- import { checkpointRecord } from '../src/spec.ts'
- import type { CheckpointRecord } from '../src/spec.ts'
- declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionStateMap {
- 'cache-test/marks': MarksState
- 'cache-test/marks2': Map<string, string>
- }
- interface SessionProjectionMap {
- 'cache-test/marks': { marks: string[] }
- }
- }
- declare module '@deepseek-ai/dsh-session/types' {
- interface SessionEventMap {
- 'cache-test/mark': { marks: string[] }
- }
- interface OutOfBandSessionEventMap {
- 'cache-test/mark': true
- }
- }
- type MarksState = { marks: string[] } | null
- const marksUnit = (stateVersion = 1) => ({
- key: 'cache-test/marks',
- stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
- init: () => null,
- apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
- wire: {
- viewSchema: z.object({ marks: z.array(z.string()) }),
- view: state => state ?? { marks: [] },
- },
- stateVersion,
- }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
- /** One session's cache file under the cache's own root. */
- const cachePath = (root: string, id: Session['id']): string =>
- join(root, String(id), 'projection_cache.json')
- /** Header shape for cachedSnapshot calls. */
- const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
- ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
- interface HarnessOptions {
- root?: string
- config?: { writeEveryEvents: number; writeIntervalMs: number }
- stateVersion?: number
- }
- const contexts: Context[] = []
- const roots: string[] = []
- async function harness(options: HarnessOptions = {}) {
- const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- const ctx = new Context()
- contexts.push(ctx)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(marksUnit(options.stateVersion))
- const fiber = await ctx.plugin(SessionProjectionCache, {
- root,
- ...options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 },
- })
- return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
- }
- const mark = (session: Session, marks: string[]): SessionEvent =>
- session.append('cache-test/mark', { marks })
- const endTurn = (session: Session): SessionEvent =>
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- /** The stored record for one session id (undefined = absent or unreadable). */
- async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
- try {
- return checkpointRecord.parse(JSON.parse(await readFile(cachePath(root, id), 'utf8')))
- } catch {
- return undefined
- }
- }
- /** The stored rows for one session id (undefined = absent or unreadable). */
- async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
- return (await storedRecord(root, id))?.rows
- }
- /** Pre-seed one session's cache file with a stored checkpoint record. */
- async function seedRecord(
- root: string,
- id: string,
- rows: CheckpointRecord['rows'],
- identity: CheckpointRecord['identity'] = { createdAt: 0 },
- ): Promise<void> {
- await mkdir(join(root, id), { recursive: true })
- await writeFile(cachePath(root, SessionId(id)), JSON.stringify({ identity, rows }))
- }
- /** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */
- const settle = () => new Promise(resolve => setTimeout(resolve, 40))
- afterEach(async () => {
- vi.useRealTimers()
- await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
- await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
- })
- describe('SessionProjectionCache write policy', () => {
- it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
- const { ctx, root } = await harness()
- const session = ctx.sessions.create(SessionId('turn-end'))
- mark(session, ['a'])
- expect(await storedRows(root, session.id)).toBeUndefined() // throttled: no write yet
- const end = endTurn(session)
- await settle()
- const rows = await storedRows(root, session.id)
- expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
- })
- it('writes at session disposal (detach, the live-to-cold moment)', async () => {
- const { ctx, root } = await harness()
- // Sessions dispose with their owning fiber: create in a child plugin.
- let session: Session | undefined
- const owner = await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('detach'))
- }, { inject: ['sessions'] }))
- if (session === undefined) throw new Error('session was not created')
- mark(session, ['live'])
- await owner.dispose()
- await settle()
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
- })
- it('flushes when the in-turn event count reaches the configured threshold', async () => {
- const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
- const session = ctx.sessions.create(SessionId('count'))
- mark(session, ['1'])
- mark(session, ['2'])
- await settle()
- expect(await storedRows(root, session.id)).toBeUndefined()
- mark(session, ['3'])
- await settle()
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
- })
- it('flushes on the configured interval when the count threshold is not reached', async () => {
- const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
- const session = ctx.sessions.create(SessionId('interval'))
- mark(session, ['slow'])
- await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
- expect(await storedRows(root, session.id)).toBeUndefined()
- await settle() // past the interval; the fire-and-forget write lands
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
- })
- it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
- const { ctx, root } = await harness()
- // Never dirtied: no events — write() still lands the init-derived cut.
- const clean = ctx.sessions.create(SessionId('clean-write'))
- await ctx.sessionProjectionCache.write(clean)
- expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
- // A unit whose state violates the plain-JSON contract fails the write loud.
- ctx.sessionProjections.register({
- key: 'cache-test/marks2',
- stateSchema: z.custom<Map<string, string>>(() => true),
- init: () => new Map<string, string>(),
- apply: state => state,
- stateVersion: 1,
- })
- await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
- })
- it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
- vi.useFakeTimers()
- const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
- const armed = ctx.sessions.create(SessionId('armed'))
- const cleaned = ctx.sessions.create(SessionId('cleaned'))
- mark(armed, ['pending']) // timer armed, no write yet
- mark(cleaned, ['done'])
- endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map
- await vi.advanceTimersByTimeAsync(0)
- await fiber.dispose()
- // The armed timer died with the plugin: advancing time writes nothing.
- await vi.advanceTimersByTimeAsync(10_000)
- expect(await storedRows(root, armed.id)).toBeUndefined()
- })
- it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
- const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- const ctx = new Context()
- contexts.push(ctx)
- await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(marksUnit())
- await ctx.plugin(SessionProjectionCache, { root, writeEveryEvents: 100, writeIntervalMs: 60_000 })
- const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
- const session = ctx.sessions.create(SessionId('fail-soft'))
- // A directory where the cache file must land makes the atomic rename
- // fail on the first write...
- await mkdir(cachePath(root, session.id), { recursive: true })
- mark(session, ['x'])
- endTurn(session)
- await settle()
- expect(await storedRows(root, session.id)).toBeUndefined()
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
- // Self-heal: once the blocker clears, the next mandatory point writes.
- await rm(cachePath(root, session.id), { recursive: true })
- mark(session, ['y'])
- endTurn(session)
- await settle()
- expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
- })
- })
- describe('SessionProjectionCache listing read', () => {
- it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
- const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
- const { cache } = await harness({ root })
- const id = SessionId('listed')
- // Matching header: values plus the watermark the client seeds under.
- expect(await cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
- // A recreated id (different createdAt): the record is unrelated — no block.
- expect(await cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
- // Unknown id: no block.
- expect(await cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
- })
- it('returns undefined when every stored row is version-mismatched', async () => {
- const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- await seedRecord(root, 'all-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
- const { cache } = await harness({ root })
- expect(await cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
- })
- it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
- const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' })
- const { cache } = await harness({ root })
- const id = SessionId('homed')
- expect((await cache.cachedSnapshot(headerOf(id, 0, '/work')))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
- expect(await cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
- expect(await cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
- })
- it('returns undefined for a malformed cache file (refold from the log on the caller side)', async () => {
- const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
- roots.push(root)
- await mkdir(join(root, 'malformed'), { recursive: true })
- await writeFile(cachePath(root, SessionId('malformed')), 'not json at all')
- const { cache } = await harness({ root })
- expect(await cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
- })
- })
|