feat(session): metadata seam + JSON-serializability invariant

Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:

- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
  dsh-session; Session gains a readonly `header`; SessionStore.create takes
  `(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
  lineage). The injection TurnTrigger variant is added for the idle-inject
  one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
  rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
  arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
  constructor validates every seed event (isJsonValue + contiguous seq from
  0), so a replay/fork seed can never build a live log no backend can
  persist — the source-level guarantee a durable backend relies on.

Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
This commit is contained in:
Tianyi Cui
2026-06-15 17:54:55 +08:00
parent 2df41ee1d3
commit 0731ed374b
8 changed files with 349 additions and 27 deletions

View File

@@ -36,6 +36,16 @@ describe('session-log invariants', () => {
}).not.toThrow()
})
it('rejects a non-monotonic seq (replay spine)', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
// Session.append enforces seq-contiguity at the source, so drive the
// invariants seq check directly via session/event with a regressing seq.
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
expect(() => { ctx.emit('session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) })
.toThrow(/seq must strictly increase/)
})
it('rejects a turn/start while another turn is open', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
@@ -98,13 +108,15 @@ describe('session-log invariants', () => {
it('holds seeded sessions to the contract on session/created', async () => {
const { ctx } = await setup({ freeze: false })
// A seed whose seq is non-monotonic must be rejected when the session is
// created (the constructor copies the seed without emitting session/event).
// A seq-contiguous, serializable seed (so it passes Session's constructor
// validation) that nonetheless violates turn nesting — a second turn/start
// while the first turn is still open — must be rejected by the invariants
// plugin when it replays the seed on session/created.
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
]
expect(() => ctx.sessions.create(undefined, badSeed)).toThrow(InvariantError)
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
})
it('tracks turns per session independently', async () => {
@@ -218,7 +230,7 @@ describe('dev-freeze', () => {
const seed = [
{ type: 'user/message' as const, seq: 0, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
]
const session = ctx.sessions.create(undefined, seed)
const session = ctx.sessions.create(undefined, { seed })
expect(Object.isFrozen(session.events[0])).toBe(true)
})
@@ -240,10 +252,17 @@ describe('dev-freeze', () => {
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
// A self-referential structure must not loop forever.
// The deep-freeze WeakSet guard must terminate on a self-referential
// structure rather than recursing forever. Session.append now rejects
// non-serializable (incl. cyclic) data at the source, so drive the freeze
// handler directly via hand-built session/events — exactly the shape the
// invariants listener receives. Open a turn first (seq 0) so the cyclic
// user/message (seq 1) satisfies seq-contiguity.
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
expect(() => session.append('user/message', { content: [cyclic as never], source: { kind: 'user' } })).not.toThrow()
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
expect(() => { ctx.emit('session/event', session, event as never) }).not.toThrow()
expect(Object.isFrozen(cyclic)).toBe(true)
})
})