feat(gui): client projection cells — session dispatch seam, one-watermark fold, service roster

Object layer of the session-projection RFC client base: ProjectionCellSpec/
ProjectionCell/ProjectionCellSet with the single seq-watermark rule (live and
window-replace events share one filter; baseline reset re-seeds value+watermark
unless a newer commit applied; absent key = capability absent), Session
dispatch at appendLive/installWindow (projections block read structurally,
TODO(gui) switch to the interface package), SessionsService.registerProjectionCell
roster (live scopes now + future scopes at mint; disposer sweeps every session),
and the provideInfo projections face (key-addressed bare cell sources).
15 object-layer specs: watermark no-rollback, late-baseline seq rule,
capability absence, schema-failure degrade, duplicate-key throw, resync e2e.
This commit is contained in:
imccyu
2026-07-27 15:37:17 +08:00
parent ed72b56f53
commit fbebe1757a
4 changed files with 562 additions and 7 deletions

View File

@@ -0,0 +1,240 @@
/**
* Projection cells (session-projection RFC): the one watermark rule shared by
* live and window paths (replayed pages never roll back), baseline reset
* semantics (late baseline never overwrites a newer commit), capability
* absence as undefined, and the Session/SessionsService dispatch wiring.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { ProjectionCellSet } from '../src/client/sessions/projection-cell.ts'
import type { ProjectionCellSpec } from '../src/client/sessions/projection-cell.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
// Test-domain key merged into the (placeholder) projection map: a whole-value
// marker list, the smallest last-wins shape.
declare module '../src/client/sessions/projection-cell.ts' {
interface SessionProjectionMap {
'test/marks': { marks: string[] }
}
}
const SID = 'fk-s1' as SessionId
/** Whole-value domain event carrying the complete post-change state. */
const markEvent = (seq: number, marks: string[]): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type: 'test/mark', data: { marks } }) as unknown as SessionEvent
/** Loose schema: passes objects with a marks array through, throws otherwise. */
const marksSpec = (): ProjectionCellSpec<'test/marks'> => ({
key: 'test/marks',
schema: {
parse: (value) => {
if (typeof value === 'object' && value !== null && Array.isArray((value as { marks?: unknown }).marks)) {
return value as { marks: string[] }
}
throw new Error('not a marks payload')
},
},
fromEvent: (event) => ((event.type as string) === 'test/mark'
? (event as unknown as { data: { marks: string[] } }).data
: undefined),
})
describe('ProjectionCellSet semantics', () => {
function bench() {
const set = new ProjectionCellSet()
const dispose = set.register(marksSpec())
const cell = set.cellOf('test/marks')
if (cell === undefined) throw new Error('cell missing after register')
return { set, cell, dispose }
}
it('starts absent (undefined) until any signal lands', () => {
const { cell } = bench()
expect(cell.getSnapshot()).toBeUndefined()
})
it('applies whole values last-wins by seq and never rolls back on replayed old events', () => {
const { set, cell } = bench()
set.offerEvent(markEvent(5, ['a']))
set.offerEvent(markEvent(9, ['a', 'b']))
expect(cell.getSnapshot()).toEqual({ marks: ['a', 'b'] })
// A replayed old page (window path) passes the same filter and drops.
set.offerWindow([markEvent(3, ['stale']), markEvent(9, ['a', 'b'])])
expect(cell.getSnapshot()).toEqual({ marks: ['a', 'b'] })
})
it('re-seeds value and watermark from a baseline, and events at or below asOfSeq drop after it', () => {
const { set, cell } = bench()
set.resetBaseline({ asOfSeq: 20, values: { 'test/marks': { marks: ['x'] } } })
expect(cell.getSnapshot()).toEqual({ marks: ['x'] })
set.offerEvent(markEvent(18, ['older-than-cut']))
expect(cell.getSnapshot()).toEqual({ marks: ['x'] })
set.offerEvent(markEvent(21, ['newer']))
expect(cell.getSnapshot()).toEqual({ marks: ['newer'] })
})
it('drops a late baseline whose cut predates an already-applied commit (seq rule)', () => {
const { set, cell } = bench()
set.offerEvent(markEvent(30, ['live-commit']))
set.resetBaseline({ asOfSeq: 25, values: { 'test/marks': { marks: ['stale-baseline'] } } })
expect(cell.getSnapshot()).toEqual({ marks: ['live-commit'] })
})
it('marks a key absent when the block omits it — capability absence is undefined', () => {
const { set, cell } = bench()
set.offerEvent(markEvent(5, ['a']))
set.resetBaseline({ asOfSeq: 10, values: {} })
expect(cell.getSnapshot()).toBeUndefined()
})
it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => {
const { set, cell } = bench()
set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' } })
expect(cell.getSnapshot()).toBeUndefined()
// The watermark still advanced to the cut: pre-cut events stay dropped.
set.offerEvent(markEvent(8, ['pre-cut']))
expect(cell.getSnapshot()).toBeUndefined()
})
it('throws on duplicate key registration and frees the key through the disposer', () => {
const { set, dispose } = bench()
expect(() => set.register(marksSpec())).toThrow(/already registered/)
dispose()
expect(set.cellOf('test/marks')).toBeUndefined()
expect(() => set.register(marksSpec())).not.toThrow()
})
it('notifies subscribers on application (microtask-batched) and not on filtered events', async () => {
const { set, cell } = bench()
let ticks = 0
cell.subscribe(() => { ticks += 1 })
set.offerEvent(markEvent(5, ['a']))
await Promise.resolve()
expect(ticks).toBe(1)
set.offerEvent(markEvent(3, ['replay']))
set.offerEvent({ seq: 6, time: 6, type: 'unrelated/event', data: {} } as unknown as SessionEvent)
await Promise.resolve()
expect(ticks).toBe(1)
})
})
describe('Session dispatch wiring', () => {
function makeSession() {
const api = new FakeApiClient()
const session = new Session(SID, api)
const dispose = session.projections.register(marksSpec())
const cell = session.projections.cellOf('test/marks')
if (cell === undefined) throw new Error('cell missing after register')
return { api, session, cell, dispose }
}
it('feeds live appends through the cell filter', async () => {
const { api, session, cell } = makeSession()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false }))
await session.open()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['live']) })
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
})
it('re-seeds from a history response carrying a projections block, then folds newer window events', async () => {
const { api, session, cell } = makeSession()
const window = [...plainTurn(0, 0, '问', '答'), markEvent(6, ['from-window'])]
api.onHistory = () => Promise.resolve(ok({
events: entries(window) as never[], hasMore: false,
projections: { asOfSeq: 4, values: { 'test/marks': { marks: ['from-baseline'] } } },
} as never))
await session.open()
// Baseline cut at 4; the window's seq-6 domain event is newer and wins.
expect(cell.getSnapshot()).toEqual({ marks: ['from-window'] })
})
it('treats a blockless response as event-only folding (no reset), and a resync repull cannot roll back', async () => {
const { api, session, cell } = makeSession()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
expect(cell.getSnapshot()).toBeUndefined()
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['live']) })
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
// Reconnect resync repulls the same window (no block, no domain events): state holds.
await session.resync()
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
})
it('applies the stale-baseline guard end to end: a resync whose block predates a live commit keeps the commit', async () => {
const { api, session, cell } = makeSession()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
} as never))
await session.open()
expect(cell.getSnapshot()).toEqual({ marks: ['baseline'] })
// Contiguous live commit applies immediately (seq 6 = tail 5 + 1)…
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['commit-6']) })
expect(cell.getSnapshot()).toEqual({ marks: ['commit-6'] })
// …then a resync repull serves the same stale block (cut 5 < applied 6):
// the baseline reset must not overwrite the newer commit (seq rule).
await session.resync()
expect(cell.getSnapshot()).toEqual({ marks: ['commit-6'] })
})
})
describe('SessionsService roster', () => {
const sid = (s: string): SessionId => s as SessionId
async function bench() {
const ctx = new Context()
const api = new FakeApiClient()
const svc = new SessionsService(ctx, api)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await svc.refresh()
await Promise.resolve()
return { ctx, api, svc }
}
it('materializes registered specs on already-live scopes and future scopes alike', async () => {
const b = await bench()
const binding1 = b.svc.binding(sid('s1'))
if (binding1 === undefined) throw new Error('no binding for s1')
b.svc.registerProjectionCell(marksSpec())
expect(binding1.session.projections.cellOf('test/marks')).toBeDefined()
// A session arriving later gets the roster at scope mint.
b.api.onList = () => Promise.resolve(ok({
items: [
{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false },
{ sessionId: sid('s2'), updatedAt: 2, running: false, blank: false },
],
}) as never)
await b.svc.refresh()
await Promise.resolve()
const binding2 = b.svc.binding(sid('s2'))
expect(binding2?.session.projections.cellOf('test/marks')).toBeDefined()
})
it('exposes the key-addressed cell face on provideInfo (the useProjection resolution path)', async () => {
const b = await bench()
b.svc.registerProjectionCell(marksSpec())
const info = b.svc.provideInfo('s1')
if (info === undefined) throw new Error('no provide info for s1')
expect(info.projections?.cellOf('test/marks')).toBeDefined()
expect(info.projections?.cellOf('test/ghost')).toBeUndefined()
// The no-session projection carries no face: every key reads absent.
expect(b.svc.maybeProvideInfo(undefined).projections).toBeUndefined()
})
it('removes the cell from every live session through the disposer (HMR semantics)', async () => {
const b = await bench()
const dispose = b.svc.registerProjectionCell(marksSpec())
const binding = b.svc.binding(sid('s1'))
expect(binding?.session.projections.cellOf('test/marks')).toBeDefined()
dispose()
expect(binding?.session.projections.cellOf('test/marks')).toBeUndefined()
})
})