SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update() were dead state: zero production callers of update(), no production reader of updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not storage. The live Session.header was already typed SessionHeader, so the summary only ever existed in the persistence layer, written and read by nothing but its own contract test. Delete it entirely (no SessionMeta alias — SessionMeta collapses to SessionHeader everywhere). This removes the JSONL .summary.json sidecar machinery, the SQLite title/first_prompt/updated_at columns and per-append updated_at bump, and the update() method from the abstract service and both backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any non-current user_version (older or newer) — no migration, unreleased software. Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability divergence that the upcoming write coordinator would otherwise have to model. Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md and migrates the 2026-06-14 session-persistence RFC's facts to current truth. Adds a standalone AGENTS.md section "Tests document behavior, not golden truth" (a passing test pins current behavior, not necessarily correct behavior) with the summary-drop as its worked example, and reinforces the no-migration pre-release stance.
151 lines
7.4 KiB
TypeScript
151 lines
7.4 KiB
TypeScript
/**
|
|
* The durable session-persistence seam (`ctx.sessionPersistence`): an abstract
|
|
* service defining WHAT a persistence backend does — durably store, reload,
|
|
* and list sessions — without saying HOW. Implementations subclass
|
|
* {@link SessionPersistence} and register themselves as the
|
|
* `sessionPersistence` service; `@deepseek-ai/dsh-session-persistence-jsonl`
|
|
* (an append-only JSONL log per session) is the first and
|
|
* `@deepseek-ai/dsh-session-persistence-sqlite` (`node:sqlite`, one row per
|
|
* event) is a second that validates the seam is backend-agnostic by passing
|
|
* the same `runPersistenceContract` suite. Further backends swap in an object
|
|
* store or a remote service without touching the consumers (the write-path
|
|
* plugin, the agent-loop resume seam).
|
|
*
|
|
* The persisted unit IS the existing {@link SessionEvent} — there is no
|
|
* parallel "persisted message" type the log must be converted to and from
|
|
* (faithful to the event-sourced model: the log is the single source of
|
|
* truth). Metadata that is NOT replayable conversation state (format version,
|
|
* cwd, lineage) travels separately as {@link SessionHeader}, which is owned by
|
|
* `dsh-session` and re-exported here.
|
|
*
|
|
* @module @deepseek-ai/dsh-session-persistence
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import { isJsonValue } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
|
|
// Re-export the metadata vocabulary so consumers import it from the seam.
|
|
export type { SessionHeader } from '@deepseek-ai/dsh-session'
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
sessionPersistence: SessionPersistence
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a live session's seed reproduces a persisted prefix exactly. Backends
|
|
* use this collision check to distinguish a legitimate resume/HMR rebind from a
|
|
* different live session reusing an existing session id.
|
|
*
|
|
* The comparison includes the full event payload, not just seq/type/time, so a
|
|
* mutated seed cannot be grafted onto a durable log with the same envelope.
|
|
*/
|
|
export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
|
|
return prefix.length <= seed.length
|
|
&& prefix.every((event, index) => {
|
|
const seedEvent = seed[index]
|
|
return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reject non-JSON-serializable event data before a backend serializes a batch.
|
|
* Live session appends already enforce this; persistence append paths also
|
|
* accept replay/fork batches that may bypass a live session instance.
|
|
*/
|
|
export function assertSerializable(events: readonly SessionEvent[]): void {
|
|
for (const event of events) {
|
|
if (!isJsonValue(event.data)) {
|
|
throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abstract durable session-persistence service. Subclass, implement the
|
|
* abstract methods, and load the subclass as a plugin — it registers as
|
|
* `ctx.sessionPersistence` (one implementation per context; loading a second
|
|
* throws, cordis' standard duplicate-service behavior).
|
|
*
|
|
* Contracts every implementation MUST honor (a DB backend asserts them inside
|
|
* a transaction; a file backend appends at EOF):
|
|
*
|
|
* - **Append-only; a crashed turn is closed, not truncated.** Committed events
|
|
* — those at or below a flushed `turn/end` — are never rewritten. A crash can
|
|
* leave an unclosed final turn whose events are real (and possibly large);
|
|
* {@link load} preserves them and closes the orphaned turn with synthetic
|
|
* boundary events (see {@link load}). Only a never-fully-written torn tail
|
|
* fragment is discarded.
|
|
* - **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`.
|
|
* {@link load} rejects a parse error or a `seq` gap in the COMMITTED region
|
|
* (unloadable); {@link append}'s first event `seq` MUST equal the backend's
|
|
* stored next-seq (after `load` has balanced any interrupted turn).
|
|
* - **JSON-serializable data.** `SessionEventMap` is merge-extensible and
|
|
* `event.data` is typed only as `SessionEventMap[K]`, so {@link append}
|
|
* REJECTS non-JSON-serializable data with an error naming the offending
|
|
* event type. A backend snapshots (serializes/clones) each event when it
|
|
* buffers, since `session.events` hands out the live mutable object.
|
|
* - **Durability.** {@link append} returns only once the batch is durable
|
|
* (the file backend fsyncs; a DB commits). {@link create} MAY defer the
|
|
* physical write until the first {@link append} (lazy materialization).
|
|
*/
|
|
export abstract class SessionPersistence extends Service {
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'sessionPersistence')
|
|
}
|
|
|
|
/**
|
|
* Register a new session's metadata. A backend MAY defer the physical write
|
|
* until the first {@link append} (lazy materialization), in which case a
|
|
* created-but-never-appended session is absent from {@link has}/{@link list}
|
|
* — abandoned sessions leave nothing behind.
|
|
*/
|
|
abstract create(meta: SessionHeader): Promise<void>
|
|
|
|
/**
|
|
* Durably persist a batch of events (called from the write-behind drain at
|
|
* the `session/flush` checkpoint). Honors the append-only and contiguous-seq
|
|
* contracts: the first event's `seq` MUST equal the stored next-seq (after
|
|
* `load` has durably closed any interrupted turn). Rejects non-JSON-
|
|
* serializable `event.data` with an error naming the offending event type.
|
|
*/
|
|
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
|
|
|
|
/**
|
|
* Reload a session: its {@link SessionHeader} plus the event log up to the last
|
|
* durable checkpoint. Returns `meta` AND `events` so the live session is
|
|
* reconstructed with its `cwd`/lineage, not just its log.
|
|
*
|
|
* The loop only flushes at `turn/end`, so a crash can leave a durable log
|
|
* whose final turn never closed: real, fully-written events sit after the last
|
|
* `turn/end`. Those events are PRESERVED — a single turn can be huge in a
|
|
* long-horizon task, so truncating it would destroy real work — and `load`
|
|
* CLOSES the orphaned turn by durably appending the minimal synthetic boundary
|
|
* events: an error `tool/result` for every `tool-call` the crash left
|
|
* unanswered (so the rehydrated history is a valid provider transcript — a
|
|
* dangling assistant tool-call is otherwise rejected), then a `step/end` if a
|
|
* step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }`
|
|
* reason. The returned `events` therefore end on a balanced `turn/end` and are
|
|
* immediately usable as a session seed. Only a never-fully-written TORN tail
|
|
* fragment (a half-written final record) is discarded. Returned events are
|
|
* contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the
|
|
* COMMITTED region (at or before the last real `turn/end`) makes the session
|
|
* unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for
|
|
* the crash-recovery contract.
|
|
*/
|
|
abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
|
|
|
/** Lightweight listing from metadata, without a full-log parse. */
|
|
abstract list(): Promise<SessionHeader[]>
|
|
|
|
/** Whether a session is durably present (materialized). */
|
|
abstract has(id: SessionId): Promise<boolean>
|
|
|
|
/** Remove a session and all its persisted artifacts. */
|
|
abstract delete(id: SessionId): Promise<void>
|
|
}
|
|
|
|
export default SessionPersistence
|