perf(host): cut the timeline fold's per-event cost and fix the Windows cross-drive path check

Three per-event costs leave the fold: every state-changing event shallow-copied
all seven retained collections, every event re-scanned the retained requests to
count turn runs, and every tool call stringified its arguments even when the
tool can never row a file op. Cloning now follows the branch's own mutation
list, the run count rides an incremental ledger (recomputed only where a trim
replaces the array), and raw arguments are kept only where they are read.
Measured over 279 real sessions (282,783 events): the fold drops 16.8% corpus
wide, ~51% on V3-shaped logs and ~57% on sessions at the retention caps, with
state output field-identical apart from the new optional `turnRuns` field and
the intended `argsRaw` omission. `stateVersion` is unchanged, so cached rows
keep parsing and stay usable.

The Windows check rode along: `path.relative` returns an ABSOLUTE path across
drives, so the old `..`-prefix test read a foreign drive's module as a child of
the plugin's own tree and degraded version detection; `!isAbsolute(rel)` closes
it.

Review follow-up on top of the original six-file change:

- an absent `timing` slot stays absent. The selective clone materialized
  `timing: undefined`, which is not losslessly JSON-serializable, so
  `snapshotJsonValue` rejected the whole checkpoint: every projection-cache
  write for that session was skipped, and a log ending on an unpaired
  `tool/result` never got a cache row at all — every reopen then paid the full
  cold fold this change set aimed to cut;
- the no-`undefined`-member rule is now pinned on every intermediate fold
  state, not only the last one — the gap that let the above ride through a
  green suite, since reading the slot cannot tell an absent key from an
  `undefined`-valued one;
- the "which tool needs its raw arguments" gate moved beside the file-op
  parser (`opBearingTool` / `rawArgsNeeded`), so the fold no longer restates
  the tool list.

Verified: `pnpm run lint:fix && pnpm run test && pnpm run build` green (1763
tests, per-file coverage 100%), plus a field-level diff of the fold over
282,783 real events (no divergence beyond the intended deltas), the
incremental ledger cross-checked against a full scan on every event including
both trims, and a frozen-predecessor run proving no in-place write.

Co-authored-by: Nwflower <1679659@qq.com>
This commit is contained in:
听语惊花andNwflower authored and GitHub committed 2026-09-22 18:16:22 +08:00
1 parent 9932d84d32
commit 22eb2ed2f4
8 files changed
+214 -51

No files matched your search

+93 -41
View File
@@ -36,7 +36,7 @@ import type { ContentBlock, MessageSource } from './pricing'
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
import { decodeKindOfBlock, decodeSpansOfStream, firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from './logShapes'
import type { DecodeKind } from './logShapes'
import { opsOfCall, parseCallArgs } from '../shared/fileOps'
import { opBearingTool, opsOfCall, parseCallArgs, rawArgsNeeded } from '../shared/fileOps'
/**
* The runtime event envelope this fold consumes. The core
@@ -107,6 +107,16 @@ export interface TimelineState {
lastModel?: string
contextWindow?: number
requests: RequestRecord[]
/**
* The number of turn runs in `requests` (a run = consecutive records
* sharing one `turn`). Maintained incrementally at the single append site
* and recomputed when a trim replaces the array, so the retention trim's
* cap check stays O(1) per event instead of walking every retained
* request. Absent on rows folded before the field existed — the first
* post-restore event recomputes it once (the same additive-optional shape
* as `detailRev`).
*/
turnRuns?: number
events: ContextEventRecord[]
/**
* Recently removed surface nodes (stamped COPIES carrying `gone`), in
@@ -257,13 +267,18 @@ function trimState(st: TimelineState, bounds: FoldBounds): void {
// Trim by WHOLE turn-runs as soon as the run count crosses the cap —
// not only when the raw step count does — so the state stays
// deterministically at the newest ~maxKeptTurns turns (a threshold-only
// policy would oscillate: trim to 1200, regrow to 1500, trim again).
if (countTurnRuns(st.requests) > bounds.maxKeptTurns) {
// policy would oscillate: trim to 1200, regrow to 1500, trim again). The
// run count is the incremental `turnRuns` ledger; a row restored from
// cache before the field existed recomputes it once here.
st.turnRuns = st.turnRuns ?? countTurnRuns(st.requests)
if (st.turnRuns > bounds.maxKeptTurns) {
st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns)
st.turnRuns = countTurnRuns(st.requests)
}
// Pathological many-step turns: hard step backstop after the turn trim.
if (st.requests.length > bounds.maxRequestSteps) {
st.requests = st.requests.slice(-bounds.maxRequestSteps)
st.turnRuns = countTurnRuns(st.requests)
}
if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents)
// The file-op log: newest tail; the newest dropped op's seq rides
@@ -304,6 +319,7 @@ export function createTimelineState(): TimelineState {
systemTokens: 0,
toolsTokens: 0,
requests: [],
turnRuns: 0,
events: [],
archived: [],
callNames: {},
@@ -804,28 +820,47 @@ function bumpToolTotals(timing: TimingTotals, name: string, ms: number): void {
timing.tools[name] = { calls: cur.calls + 1, ms: cur.ms + ms }
}
/**
* The collections a fold branch may mutate IN PLACE (`push`/splice/element
* or property writes). A branch names exactly its own mutations in `ensure`;
* the unlisted containers (`systems`, `pendingCodeOps`, `cost`, `stepStart`,
* `signature of pendingShadowedSeqs`) are never written in place — every
* touch REPLACES the property with a fresh array/record — so they stay
* shared with the persisted previous state without a clone.
*/
type CloneKey = 'surface' | 'sums' | 'requests' | 'events' | 'archived' | 'callNames' | 'fileOps' | 'timing'
const CLONE_ALL: readonly CloneKey[] = ['surface', 'sums', 'requests', 'events', 'archived', 'callNames', 'fileOps', 'timing']
export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds: FoldBounds): TimelineState {
let st: TimelineState | undefined
const ensure = (): TimelineState => st ??= {
...state,
surface: [...state.surface],
sums: { ...state.sums },
requests: [...state.requests],
events: [...state.events],
archived: [...state.archived],
callNames: { ...state.callNames },
fileOps: [...state.fileOps],
// The pending-ops MAP is cloned here; each key's array is rebuilt on
// touch (bufferCodeOps/flush), never mutated in place — same rule.
...(state.pendingCodeOps !== undefined
? { pendingCodeOps: { ...state.pendingCodeOps } }
: {}),
// The timing totals are shared with the persisted previous state —
// private working copies for this event's accumulations (per-name rows
// are replaced, never mutated, so a one-level copy suffices for them).
...(state.timing !== undefined
? { timing: { ...state.timing, tools: { ...state.timing.tools } } }
: {}),
/**
* The event's private working state: a shallow object clone up front, with
* only the named collections cloned into private copies. Everything else
* stays shared with the persisted previous state — the apply contract is
* "never mutate the previous state in place". Called EXACTLY ONCE per event
* (each case names the collections its own path may write into; helpers
* never call it), so building afresh per call is safe. The default is the
* full clone — a future branch that omits its list degrades to the old
* behavior instead of sharing a collection it mutates.
*/
const ensure = (keys: readonly CloneKey[] = CLONE_ALL): TimelineState => {
st = { ...state }
for (const key of keys) {
if (key === 'surface') st.surface = [...state.surface]
else if (key === 'sums') st.sums = { ...state.sums }
else if (key === 'requests') st.requests = [...state.requests]
else if (key === 'events') st.events = [...state.events]
else if (key === 'archived') st.archived = [...state.archived]
else if (key === 'callNames') st.callNames = { ...state.callNames }
else if (key === 'fileOps') st.fileOps = [...state.fileOps]
// An ABSENT `timing` stays absent: a branch may name the key without
// ever reaching `ensureTiming` (an unpaired tool/result prices no call),
// and an own `undefined`-valued property fails EVERY projection-cache
// write for the session (the plain-JSON precondition, see TimelineState).
else if (state.timing !== undefined) st.timing = { ...state.timing, tools: { ...state.timing.tools } }
}
return st
}
const data = event.data
@@ -844,7 +879,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
config?: { model?: unknown; provider?: unknown }
}
const tools = Array.isArray(header.tools) ? header.tools : []
const s = ensure()
const s = ensure(['events'])
// Tools TOTAL = dsh's whole-array price (one JSON string of every schema).
s.toolsTokens = estimateToolsTotal(tools)
// The V0/V2 system prompt rides this ENVELOPE; V3 rejects it outright
@@ -891,7 +926,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// categories — it is the envelope figure's source, never a
// user/inject/assistant/tool node, so it must not enter `surface` or
// `sums` (that would double-count it against `systemTokens`).
const s = ensure()
const s = ensure(['surface', 'sums', 'archived'])
// Consume the armed shadow claim (the shadow-price protocol expires it
// on the next surface event) — a system node never carries one.
delete s.pendingShadowedSeqs
@@ -914,7 +949,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
break
}
case 'request/context': {
const s = ensure()
const s = ensure([])
// Route/capacity metadata: request/context is logged only when the route or capacity changes (after request/header), so it updates
// the current route display — never firing a model-switch event on its own.
if (data && typeof data.contextWindow === 'number') s.contextWindow = data.contextWindow
@@ -924,9 +959,13 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
case 'tool/call': {
if (data && typeof data.callId === 'string' && typeof data.name === 'string') {
const s = ensure()
// The raw arguments ride along for the result-time file-op derivation (shared/fileOps.ts).
const argsRaw = argsRawOf(data.arguments)
const s = ensure(['callNames'])
// The raw arguments ride along for the result-time file-op derivation
// (shared/fileOps.ts) — ONLY where they can be read: an op-bearing
// tool, or the run_code root whose description the flush reads.
// Skipping the stringify for the rest keeps a large bash/pwsh call's
// arguments out of the persisted state.
const argsRaw = rawArgsNeeded(data.name) ? argsRawOf(data.arguments) : undefined
s.callNames[data.callId] = {
name: data.name,
start: event.time,
@@ -948,15 +987,19 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
const rootCallId = data?.rootCallId
const name = data?.name
if (typeof rootCallId === 'string' && typeof name === 'string') {
// Same gating as tool/call, minus the run_code arm: a dispatch's
// arguments only ever feed its own op derivation, so anything that
// cannot row an op is never serialized.
const argsRaw = opBearingTool(name) ? argsRawOf(data?.arguments) : undefined
const ops = opsOfCall({
seq: event.seq,
time: event.time,
tool: name,
argsRaw: argsRawOf(data?.arguments),
argsRaw,
err: data?.isError === true,
})
if (ops.length > 0) {
const s = ensure()
const s = ensure([])
bufferCodeOps(s, rootCallId, ops)
}
}
@@ -980,7 +1023,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// An unknown marker still CLOSES the open block (its end is real);
// only the interval it would open stays unattributed.
if (start.block === undefined && kind === undefined) return state
const s = ensure()
const s = ensure([])
const decode = { ...(start.decode ?? { reasoning: 0, text: 0, toolarg: 0 }) }
if (start.block !== undefined) decode[start.block.kind] += durOf(start.block.since, event.time)
// The next block is ABSENT (not undefined-valued) when unknown — the
@@ -995,7 +1038,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
if (start.firstToken !== undefined) return state
if (!isTokenChunk(data?.chunk)) return state
const s = ensure()
const s = ensure([])
s.stepStart = { ...start, firstToken: event.time }
break
}
@@ -1009,7 +1052,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
if (start === undefined || start.firstToken !== undefined) return state
const first = firstTokenTimeOfStream(data?.stream)
if (first === undefined) return state
const s = ensure()
const s = ensure([])
s.stepStart = { time: start.time, firstToken: first }
break
}
@@ -1020,7 +1063,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// the whole step against this instant. Always a state change (a new
// slot value), even over an un-consumed predecessor — sequential logs
// never hit that, hostile ones just supersede it.
const s = ensure()
const s = ensure([])
s.stepStart = { time: event.time }
break
}
@@ -1029,7 +1072,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// refold) — nothing to price, and the state must stay reference-equal.
const start = state.stepStart
if (start === undefined) return state
const s = ensure()
const s = ensure(['timing'])
ensureTiming(s).wallMs += durOf(start.time, event.time)
// Consume-once: DELETE the optional field — assigning `undefined`
// would break the plain-JSON persisted-state precondition.
@@ -1040,7 +1083,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// `deriveEventMessage` is the canonical per-event projection: returns
// `event.data` for user/message (no `data.message` indirection).
const msg = deriveEventMessage(event as never) as MessageLike | null
const s = ensure()
const s = ensure(['surface', 'sums', 'archived', 'events'])
bumpDetailRev(s)
const node = applySurface(s, event, event.type, data, msg)
const source = msg?.source
@@ -1093,7 +1136,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
?? (typeof blockId === 'string' ? state.callNames[blockId] : undefined)
const buffered = (typeof srcId === 'string' ? state.pendingCodeOps?.[srcId] : undefined)
?? (typeof blockId === 'string' ? state.pendingCodeOps?.[blockId] : undefined)
const s = ensure()
const s = ensure(['surface', 'sums', 'archived', 'events', 'fileOps', 'timing'])
bumpDetailRev(s)
const node = applySurface(s, event, event.type, data, toolMsg)
// An answered question prompt is a human input too (whole-session
@@ -1161,7 +1204,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// Snapshot the request exactly as dispatched: current surface + header,
// before this response joins the surface.
const usage = data?.usage as UsageLike | null | undefined
const s = ensure()
const s = ensure(['surface', 'sums', 'archived', 'events', 'requests', 'timing'])
bumpDetailRev(s)
const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.skill + s.sums.assistant + s.sums.tool
const record: RequestRecord = {
@@ -1215,6 +1258,15 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
}
s.requests.push(record)
// The `turnRuns` ledger (see TimelineState): one turn run per change
// of the `turn` value, exactly `countTurnRuns`'s rule — the last
// retained request decides the delta, and a restored row without the
// field recomputes its base once.
const prevLast = state.requests.at(-1)
s.turnRuns = (state.turnRuns ?? countTurnRuns(state.requests))
+ (prevLast === undefined
? (record.turn !== undefined ? 1 : 0)
: (record.turn === prevLast.turn ? 0 : 1))
// Timing: one completed model call; its wait/generation split prices
// off the slot's first-token stamp. That stamp comes from a V0
// `assistant/chunk` delta or, when the log carries none, from the
@@ -1272,7 +1324,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// Plan mode adds a guidance section to every model request while
// active — a real context-composition change, so it earns an event.
if (data && typeof data.active === 'boolean') {
const s = ensure()
const s = ensure(['events'])
s.events.push({ seq: event.seq, time: event.time, kind: 'mode', name: data.active ? 'plan.on' : 'plan.off' })
bumpDetailRev(s)
}
@@ -1280,7 +1332,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
case 'compaction/summary':
case 'compaction/prune': {
const s = ensure()
const s = ensure(['events'])
bumpDetailRev(s)
// Arm the shadow-price claim: the replacement that follows this
// event synchronously shadows exactly these node seqs.
+1
View File
@@ -245,6 +245,7 @@ const timelineStateSchema = z.object({
lastModel: z.string().optional(),
contextWindow: z.number().optional(),
requests: z.array(requestRecordSchema),
turnRuns: z.number().int().nonnegative().optional(),
events: z.array(contextEventSchema),
archived: z.array(surfaceNodeSchema),
cost: costUsageSchema.optional(),
+8 -4
View File
@@ -34,7 +34,7 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, relative } from 'node:path'
import { dirname, isAbsolute, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
@@ -142,11 +142,15 @@ function probeAnchor(resolve: Resolve, packageNames: readonly string[]): string
/**
* Whether `candidate` lies inside `root`'s tree. A `..`-prefixed relative path
* escapes it; a sibling such as `dsh-context-extra` is reached through `..` too,
* so it never counts as a child. A pathological child literally named `..foo`
* would degrade to the home anchor, which is the safe direction.
* so it never counts as a child. On Windows, a cross-drive `relative()` returns
* the candidate's ABSOLUTE path (no `..` prefix) — a different drive is never
* inside this tree, so the absolute answer is rejected the same way. A
* pathological child literally named `..foo` would degrade to the home anchor,
* which is the safe direction.
*/
function isInside(root: string, candidate: string): boolean {
return !relative(root, candidate).startsWith('..')
const rel = relative(root, candidate)
return !rel.startsWith('..') && !isAbsolute(rel)
}
/**
+24 -1
View File
@@ -60,6 +60,26 @@ export function kindOfCall(tool: string, args: Record<string, unknown> | null):
return kindOfTool(tool)
}
/**
* Whether a settled call's arguments can yield an op at all: the file tools
* plus `str_replace_editor`, whose purpose follows its `command`. The public
* face of the gate {@link opsOfCall} short-circuits on — callers that decide
* whether to parse or to KEEP a call's raw arguments ask here instead of
* restating the tool list.
*/
export function opBearingTool(tool: string): boolean {
return tool === 'str_replace_editor' || kindOfTool(tool) !== null
}
/**
* Whether a call's raw arguments must ride the fold state
* (TimelineState.callNames): every op-bearing tool, plus `run_code`, whose
* `description` labels the ops its nested dispatches book at flush time.
*/
export function rawArgsNeeded(tool: string): boolean {
return opBearingTool(tool) || tool === 'run_code'
}
/**
* The operation's target path: the path-ish argument of read/write tools;
* for searches the narrowing `path`, else the pattern itself (a pathless
@@ -203,7 +223,10 @@ export function opsOfCall(input: {
parent?: number
program?: string
}): FileOpRecord[] {
const args = parseCallArgs(input.argsRaw)
// A non-op-bearing tool never rows one — skip its arguments parse entirely
// (a call's arguments are its largest payload, and the fold may hand a large
// bash/pwsh call here).
const args = opBearingTool(input.tool) ? parseCallArgs(input.argsRaw) : null
const kind = kindOfCall(input.tool, args)
if (kind === null) return []
const stamp: FileOpRecord = {
+26
View File
@@ -58,6 +58,23 @@ describe('the file-op log — call/result pairing', () => {
assert.deepEqual(state.fileOps, [])
})
test('a non-file call saves no raw arguments; str_replace_editor keeps and kinds them', () => {
const def = timelineDef()
let state = def.apply(def.init(), toolCall(1, { callId: 'b1', name: 'bash', arguments: JSON.stringify({ command: 'ls' }) }))
state = def.apply(state, toolCall(2, {
callId: 's1', name: 'str_replace_editor', arguments: JSON.stringify({ command: 'view', path: 'a.ts', view_range: [1, 3] }),
}))
// The args serialization is gated on tools that can row an op: bash never
// does, str_replace_editor's op derives from the arguments.
assert.equal(state.callNames.b1?.name, 'bash')
assert.ok(!('argsRaw' in (state.callNames.b1 ?? {})), 'a non-file call saves no raw arguments')
assert.equal(state.callNames.s1?.argsRaw, JSON.stringify({ command: 'view', path: 'a.ts', view_range: [1, 3] }))
state = def.apply(state, toolResult(3, { callId: 'b1', content: [{ type: 'text', text: 'ok' }] }))
state = def.apply(state, toolResult(4, { callId: 's1', content: [{ type: 'text', text: 'ok' }] }))
// A `view` command reads; the op rows off the parsed arguments.
assert.deepEqual(state.fileOps.map(o => [o.kind, o.path, o.tool]), [['read', 'a.ts', 'str_replace_editor']])
})
test('a call without raw arguments pairs for the surface label but books no op', () => {
const { state } = driveTimeline([
{ type: 'tool/call', seq: 1, time: 1, data: { callId: 'c1', name: 'read' } } as unknown as TimelineEvent,
@@ -157,6 +174,15 @@ describe('the file-op log — Code Mode (PTC) sub-dispatches', () => {
])
assert.deepEqual(state.fileOps.map(o => [o.path, o.parent ?? 0]), [['a.ts', 5], ['b.ts', 6]])
})
test('a str_replace_editor dispatch keeps its arguments and kinds by command', () => {
const { state } = driveTimeline([
toolCall(1, { callId: 'rc1', name: 'run_code', arguments: '{}' }),
codeDispatch(2, { rootCallId: 'rc1', name: 'str_replace_editor', arguments: { command: 'view', path: 'a.ts', view_range: [1, 3] } }),
toolResult(3, { callId: 'rc1', content: [{ type: 'text', text: 'ok' }] }),
])
assert.deepEqual(state.fileOps.map(o => [o.kind, o.path, o.parent ?? 0]), [['read', 'a.ts', 3]])
})
})
describe('the file-op log — retention, schema, and the served payload', () => {
+30
View File
@@ -96,6 +96,36 @@ describe('trimState request/event bounds', () => {
})
})
describe('the incremental turnRuns ledger', () => {
test('counts turn runs exactly like a full scan, including a turn-less first record', () => {
const drive = driveTimeline([
assistantMessage(1, {}),
assistantMessage(2, { turn: 1, step: 1 }),
assistantMessage(3, { turn: 1, step: 2 }),
assistantMessage(4, { turn: 2, step: 1 }),
assistantMessage(5, { turn: 2, step: 2 }),
])
// Scan semantics: the leading turn-less record forms no run; runs open at
// turn 1 (record 2) and turn 2 (record 4).
assert.equal(drive.state.turnRuns, 2)
assertStatesPlainJson(drive)
})
test('a restored row folded before the field existed recomputes its base once', () => {
const def = timelineDef()
let state = def.apply(def.init(), assistantMessage(1, { turn: 1, step: 1 }))
state = def.apply(state, assistantMessage(2, { turn: 1, step: 2 }))
// A cached row from an older build carries no `turnRuns` (additive-optional).
delete (state as { turnRuns?: unknown }).turnRuns
// A request push recomputes the base incrementally (1 retained run + the new turn).
const pushed = def.apply(state, assistantMessage(3, { turn: 2, step: 1 }))
assert.equal(pushed.turnRuns, 2)
// A non-request change recomputes in the trim pass instead.
const trimmed = def.apply(state, planMode(4, { active: true }))
assert.equal(trimmed.turnRuns, 1)
})
})
describe('trimState archive pruning', () => {
test('removals at or before the oldest retained request are dropped, recording archiveFloor', () => {
const drive = driveTimeline([
+7 -3
View File
@@ -218,9 +218,13 @@ describe('timing — tool call durations', () => {
assert.deepEqual(state.timing?.tools, { bash: { calls: 2, ms: 6_000 } })
})
test('an unpaired result carries no duration and no tally', () => {
const { state } = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })])
assert.equal(state.timing, undefined)
test('an unpaired result carries no duration, no tally, and no `timing` slot at all', () => {
const drive = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })])
assert.equal(drive.state.timing, undefined)
// ABSENT, never materialized as an `undefined`-valued property: the fold
// priced no call, so the slot stays untouched — one such property fails
// every projection-cache write for the session (the plain-JSON contract).
assert.equal(Object.hasOwn(drive.state, 'timing'), false)
})
test('result before call (out-of-order log) prices nothing', () => {
+25 -2
View File
@@ -1,7 +1,7 @@
// Fold drivers for the host specs: run event envelopes through the REAL
// projection units the plugin registers (no harness plumbing — the units are
// pure init/apply/view), with the framework's own serializer pinned on every
// intermediate state.
// pure init/apply/view), pinning the state's plain-JSON rule on every
// intermediate state (see assertNoAbsentMembers).
import assert from 'node:assert/strict'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -102,6 +102,27 @@ export function assertPlainJson<T>(state: T): T {
return copy as T
}
/**
* The one plain-JSON rule a DURABLE log can break (see TimelineState): no own
* `undefined`-valued property may enter a folded state — one such property
* fails EVERY projection-cache write for that session from then on. The rest
* of the lossless contract cannot arrive through a real log (the durable
* vocabulary is JSON: no non-finite numbers, holes, or cycles, and dsh
* validates every payload before appending it), so `assertPlainJson` stays the
* explicit whole-contract check for the specs that mean to assert it.
*
* Applied to every state the fold PRODUCES, not only the last one: a mid-log
* violation sticks even when a later event overwrites the offending key.
*/
function assertNoAbsentMembers(value: unknown, path = '$', seen = new Set<object>()): void {
if (value === null || typeof value !== 'object' || seen.has(value)) return
seen.add(value)
for (const [key, member] of Object.entries(value)) {
if (member === undefined) assert.fail(`${path}.${key} must stay ABSENT, never carry undefined`)
assertNoAbsentMembers(member, `${path}.${key}`, seen)
}
}
export interface TimelineDrive {
def: TimelineDefLike
state: TimelineState
@@ -114,9 +135,11 @@ export interface TimelineDrive {
export function driveTimeline(events: TimelineEvent[], config?: Config): TimelineDrive {
const def = timelineDef(config)
let state = def.init()
assertNoAbsentMembers(state)
const states = [state]
for (const ev of events) {
state = def.apply(state, ev)
assertNoAbsentMembers(state)
states.push(state)
}
return { def, state, states, view: def.wire.view(state) }