refactor(session): drop the dead mutable SessionSummary
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.
This commit is contained in:
@@ -2,15 +2,15 @@
|
||||
* On-disk format helpers for the JSONL session-persistence backend: path
|
||||
* sanitization (a {@link SessionId} is an unvalidated branded string, so it
|
||||
* MUST be encoded before use in a path — no traversal, no collision), the
|
||||
* per-cwd directory layout, header-line (de)serialization, the atomic sidecar
|
||||
* for mutable summary fields, and the truncation-repair offset computation.
|
||||
* per-cwd directory layout, header-line (de)serialization, and the
|
||||
* truncation-repair offset computation.
|
||||
*
|
||||
* @module dsh-session-persistence-jsonl/format
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type { SessionEvent, SessionHeader, SessionId, SessionMeta } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The first line of a session's `.jsonl` file: the immutable
|
||||
@@ -109,11 +109,6 @@ export function logPath(root: string, cwd: string | undefined, id: SessionId): s
|
||||
return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`)
|
||||
}
|
||||
|
||||
/** The mutable-summary sidecar path for a session (beside its log). */
|
||||
export function sidecarPath(root: string, cwd: string | undefined, id: SessionId): string {
|
||||
return join(sessionDir(root, cwd), `${encodeSegment(id)}.summary.json`)
|
||||
}
|
||||
|
||||
/** Serialize one event as a JSONL line (no trailing newline). */
|
||||
export function eventLine(event: SessionEvent): string {
|
||||
return JSON.stringify(event)
|
||||
@@ -138,7 +133,7 @@ export function eventLine(event: SessionEvent): string {
|
||||
* (`Session.append` enforces it): only the final turn can be open, so the
|
||||
* preserved tail is at most one unclosed turn.
|
||||
*/
|
||||
export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEvent[]; committedBytes: number } {
|
||||
export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
|
||||
const text = buffer.toString('utf8')
|
||||
// Split into complete (newline-terminated) lines, tracking the byte offset of
|
||||
// each line's end so the truncation point is exact (multi-byte chars make the
|
||||
@@ -233,26 +228,16 @@ export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEve
|
||||
// synthetic closers + new events.
|
||||
const lastPreserved = parsed[preserved.length - 1]
|
||||
const committedBytes = preserved.length > 0 && lastPreserved ? lastPreserved.endByte : headerEntry.endByte
|
||||
return { meta: metaFrom(headerLine), events: preserved, committedBytes }
|
||||
}
|
||||
|
||||
/** Build the load-time {@link SessionMeta} from a header line (summary overlaid later). */
|
||||
function metaFrom(headerLine: HeaderLine): SessionMeta {
|
||||
return {
|
||||
...fromHeaderLine(headerLine),
|
||||
updatedAt: headerLine.createdAt, // overlaid by the sidecar in load()
|
||||
}
|
||||
return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse just the header line of a log into load-time {@link SessionMeta}, or
|
||||
* Parse just the header line of a log into a {@link SessionHeader}, or
|
||||
* `undefined` if it is missing/not a header. Used by `list()` to read session
|
||||
* metadata WITHOUT parsing the whole log: a session picker scales with the
|
||||
* number of sessions, not the total size of every conversation. The summary
|
||||
* sidecar is overlaid by the caller; `updatedAt` here mirrors `createdAt` until
|
||||
* then (same as {@link scanLog}'s load-time meta).
|
||||
* number of sessions, not the total size of every conversation.
|
||||
*/
|
||||
export function parseHeaderMeta(firstLine: string): SessionMeta | undefined {
|
||||
export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(firstLine)
|
||||
@@ -260,5 +245,5 @@ export function parseHeaderMeta(firstLine: string): SessionMeta | undefined {
|
||||
return undefined
|
||||
}
|
||||
if (!isHeaderLine(parsed)) return undefined
|
||||
return metaFrom(parsed)
|
||||
return fromHeaderLine(parsed)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
*
|
||||
* 1. **The backend** — a concrete {@link SessionPersistence}: one append-only
|
||||
* `.jsonl` event log per session (a header line then one `SessionEvent` per
|
||||
* line, verbatim including `assistant/chunk` so `seq` stays contiguous) plus
|
||||
* a small atomic `.summary.json` sidecar for the mutable `SessionSummary`.
|
||||
* line, verbatim including `assistant/chunk` so `seq` stays contiguous).
|
||||
* Lazy materialization (no file until the first `append`), atomic first
|
||||
* write, and load-time repair of a never-committed crash tail.
|
||||
*
|
||||
@@ -22,16 +21,16 @@
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { open, mkdir, readFile, readdir, rename, link, rm, truncate } from 'node:fs/promises'
|
||||
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
SessionPersistence, assertSerializable, seedCoversPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import { interruptedTurnClosers } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, sidecarPath, toHeaderLine,
|
||||
encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
} from './format.ts'
|
||||
|
||||
export interface Config {
|
||||
@@ -45,7 +44,7 @@ export interface Config {
|
||||
|
||||
/** Per-session write state held by the backend's in-memory bookkeeping. */
|
||||
interface SessionState {
|
||||
meta: SessionMeta
|
||||
meta: SessionHeader
|
||||
/** The next seq the backend expects to append (the stored log length). */
|
||||
cursor: number
|
||||
/** Whether the `.jsonl` file has been physically materialized. */
|
||||
@@ -130,17 +129,17 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
|
||||
// --- SessionPersistence backend surface (all serialized per session id) ---
|
||||
|
||||
create(meta: SessionMeta): Promise<void> {
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
// Snapshot the metadata at call time: the op runs later (behind the
|
||||
// per-session chain) and the snapshot is also stored as the lazy state, so
|
||||
// keeping the caller's object by reference would let a later mutation of
|
||||
// `id`/`cwd` register under one key but materialize under a different
|
||||
// path/header. A shallow copy is enough — SessionMeta is a flat record.
|
||||
const snapshot: SessionMeta = { ...meta }
|
||||
// path/header. A shallow copy is enough — SessionHeader is a flat record.
|
||||
const snapshot: SessionHeader = { ...meta }
|
||||
return this.serialize(snapshot.id, () => this.createCore(snapshot))
|
||||
}
|
||||
|
||||
private async createCore(meta: SessionMeta): Promise<void> {
|
||||
private async createCore(meta: SessionHeader): Promise<void> {
|
||||
// Do NOT clobber an existing session. If we already track it, or a log
|
||||
// exists on disk under this id, refuse — the SessionId IS the identity, and
|
||||
// silently resetting state (cursor 0, materialized false) over committed
|
||||
@@ -218,18 +217,15 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
await this.appendLines(state, events)
|
||||
}
|
||||
// The durable event log is the transaction: advance the cursor as soon as
|
||||
// the log write commits. The sidecar (mutable summary) is best-effort here
|
||||
// — a failed sidecar write must NOT reject an append whose log already
|
||||
// landed (that would desync the cursor and let a retry duplicate seqs).
|
||||
// the log write commits.
|
||||
state.cursor += events.length
|
||||
await this.touchSummary(state).catch(() => { /* sidecar is recoverable metadata; log is durable */ })
|
||||
}
|
||||
|
||||
load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
|
||||
load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return this.serialize(id, () => this.loadCore(id))
|
||||
}
|
||||
|
||||
private async loadCore(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
|
||||
private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
const cwd = this.states.get(id)?.meta.cwd
|
||||
const file = await this.findLog(id, cwd)
|
||||
if (file === undefined) throw new Error(`session "${id}" not found`)
|
||||
@@ -237,9 +233,6 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
const { meta, events, committedBytes } = scanLog(buffer)
|
||||
this.assertVersion(meta)
|
||||
|
||||
const summary = await this.readSidecar(id, meta.cwd)
|
||||
const fullMeta: SessionMeta = { ...meta, ...summary }
|
||||
|
||||
// Crash-recovery: if the log ended mid-turn (an open turn with real,
|
||||
// preserved events but no closing turn/end), close it durably DURING load so
|
||||
// disk, the returned log, and the cursor all agree — both append routes then
|
||||
@@ -253,7 +246,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
// Set state BEFORE the repair writes so they can resolve the log path.
|
||||
const needsTorn = committedBytes < buffer.byteLength
|
||||
const state: SessionState = {
|
||||
meta: { ...fullMeta },
|
||||
meta: { ...meta },
|
||||
cursor: events.length,
|
||||
materialized: true,
|
||||
}
|
||||
@@ -267,15 +260,12 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
if (closers.length > 0) {
|
||||
// Durably append the synthetic closers, then advance the cursor to the
|
||||
// balanced length. After this, disk == balanced and the next append (live
|
||||
// or direct) continues cleanly. No sidecar touch here: load is not a
|
||||
// summary-changing op (the closers carry no new title/firstPrompt), and
|
||||
// the next real append bumps `updatedAt` — keeping the summary write off
|
||||
// the recovery path avoids a second best-effort failure mode.
|
||||
// or direct) continues cleanly.
|
||||
await this.appendLines(state, closers)
|
||||
state.cursor = balanced.length
|
||||
}
|
||||
|
||||
return { meta: fullMeta, events: balanced }
|
||||
return { meta, events: balanced }
|
||||
}
|
||||
|
||||
private async adoptLiveDiskPrefix(
|
||||
@@ -290,9 +280,8 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
const summary = await this.readSidecar(session.header.id, meta.cwd)
|
||||
const state: SessionState = {
|
||||
meta: { ...meta, ...summary },
|
||||
meta: { ...meta },
|
||||
cursor: events.length,
|
||||
materialized: true,
|
||||
owner: session,
|
||||
@@ -306,8 +295,8 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
const metas: SessionMeta[] = []
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
const metas: SessionHeader[] = []
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
for (const name of await this.listJsonl(dir)) {
|
||||
// Read ONLY the header line, not the whole log: a session picker must
|
||||
@@ -318,8 +307,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
if (first === undefined) continue // empty/half-written file
|
||||
const meta = parseHeaderMeta(first)
|
||||
if (meta === undefined) continue // not a session header
|
||||
const summary = await this.readSidecarForList(meta.id, meta.cwd)
|
||||
metas.push({ ...meta, ...summary })
|
||||
metas.push(meta)
|
||||
}
|
||||
}
|
||||
return metas
|
||||
@@ -367,41 +355,9 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
const cwd = this.states.get(id)?.meta.cwd
|
||||
const file = await this.findLog(id, cwd)
|
||||
if (file) await rm(file.path, { force: true })
|
||||
// Remove the sidecar too. A lazy session (update() before the first
|
||||
// append()) has a `.summary.json` sidecar but NO `.jsonl` log, and after a
|
||||
// restart the in-memory cwd is gone — so keying sidecar removal off the log
|
||||
// or the in-memory cwd would leak its possibly-sensitive title/firstPrompt.
|
||||
// Scan every cwd bucket for the sidecar by its (sanitized) filename.
|
||||
await this.removeSidecars(id)
|
||||
this.states.delete(id)
|
||||
}
|
||||
|
||||
/** Remove a session's summary sidecar from EVERY cwd bucket (id is unique). */
|
||||
private async removeSidecars(id: SessionId): Promise<void> {
|
||||
const target = `${encodeSegment(id)}.summary.json`
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
await rm(`${dir}/${target}`, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
update(id: SessionId, summary: Partial<SessionSummary>): Promise<void> {
|
||||
return this.serialize(id, () => this.updateCore(id, summary))
|
||||
}
|
||||
|
||||
private async updateCore(id: SessionId, summary: Partial<SessionSummary>): Promise<void> {
|
||||
let state = this.states.get(id)
|
||||
if (state === undefined) state = await this.adopt(id)
|
||||
// Build the NEXT meta separately and commit it to in-memory state only AFTER
|
||||
// the sidecar write succeeds. update's only durable effect is the sidecar,
|
||||
// so a failure DOES reject (unlike append, whose log is the transaction and
|
||||
// sidecar is best-effort) — but if we mutated state.meta first, a later
|
||||
// touchSummary() on a successful append would persist the rejected
|
||||
// title/firstPrompt, making a failed update durable after the fact.
|
||||
const nextMeta: SessionMeta = { ...state.meta, ...summary, updatedAt: summary.updatedAt ?? Date.now() }
|
||||
if (state.materialized) await this.writeSidecar(nextMeta)
|
||||
state.meta = nextMeta
|
||||
}
|
||||
|
||||
// --- materialization / append / repair ---
|
||||
|
||||
/** Atomically write the header line + first batch (temp-write, fsync, rename). */
|
||||
@@ -521,76 +477,6 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
// --- sidecar (mutable summary) ---
|
||||
|
||||
private async touchSummary(state: SessionState): Promise<void> {
|
||||
state.meta = { ...state.meta, updatedAt: Date.now() }
|
||||
await this.writeSidecar(state.meta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic sidecar write (temp-write + rename), summary fields only.
|
||||
*
|
||||
* Deliberately NOT directory-fsynced (unlike {@link materialize}): the
|
||||
* sidecar holds mutable, recoverable summary metadata (updatedAt, title,
|
||||
* firstPrompt), not source-of-truth log data. The rename is atomic so a
|
||||
* reader never sees a torn file, but a power loss may lose the most recent
|
||||
* summary — acceptable because it is re-derivable and the durable log (the
|
||||
* transaction) is independently synced. Strict crash-durability is reserved
|
||||
* for the event log.
|
||||
*/
|
||||
private async writeSidecar(meta: SessionMeta): Promise<void> {
|
||||
const dir = sessionDir(this.root, meta.cwd)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
const path = sidecarPath(this.root, meta.cwd, meta.id)
|
||||
const summary: SessionSummary = {
|
||||
updatedAt: meta.updatedAt,
|
||||
...meta.title !== undefined ? { title: meta.title } : {},
|
||||
...meta.firstPrompt !== undefined ? { firstPrompt: meta.firstPrompt } : {},
|
||||
}
|
||||
const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`
|
||||
// Exclusive owner-only create ('wx', 0o600), matching the log-materialization
|
||||
// temp write: the sidecar can carry user data (title/firstPrompt), so a
|
||||
// predictable/pre-existing temp path must never be silently truncated and
|
||||
// followed (symlink race / disclosure). The random suffix already makes a
|
||||
// collision unlikely; 'wx' makes reuse an error rather than a clobber.
|
||||
const handle = await open(tmp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(summary))
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tmp, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mutable-summary sidecar, or `undefined` if it is absent (a session
|
||||
* that has never been `update()`d). Non-ENOENT failures surface on strict
|
||||
* load/adopt paths so corrupt metadata does not masquerade as a clean default.
|
||||
*/
|
||||
private async readSidecar(id: SessionId, cwd: string | undefined): Promise<SessionSummary | undefined> {
|
||||
try {
|
||||
const raw = await readFile(sidecarPath(this.root, cwd, id), 'utf8')
|
||||
return JSON.parse(raw) as SessionSummary
|
||||
} catch (error) {
|
||||
if (isENOENT(error)) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort summary read for list(): a corrupt sidecar should degrade one
|
||||
* row to header metadata, not hide every session from a picker.
|
||||
*/
|
||||
private async readSidecarForList(id: SessionId, cwd: string | undefined): Promise<SessionSummary | undefined> {
|
||||
try {
|
||||
return await this.readSidecar(id, cwd)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`session-persistence-jsonl: ignoring unreadable summary for session "${id}" while listing: ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// --- discovery helpers ---
|
||||
|
||||
/** Find a session's log file across cwd buckets (when cwd is unknown). */
|
||||
@@ -657,7 +543,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
return state
|
||||
}
|
||||
|
||||
private assertVersion(meta: SessionMeta): void {
|
||||
private assertVersion(meta: SessionHeader): void {
|
||||
if (meta.version !== 1) {
|
||||
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`)
|
||||
}
|
||||
@@ -827,7 +713,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
|
||||
// case 4: a genuinely new session. Register its meta (lazy), then persist
|
||||
// its seed (events present at creation time) once.
|
||||
const meta: SessionMeta = { ...session.header, updatedAt: Date.now() }
|
||||
const meta: SessionHeader = { ...session.header }
|
||||
await this.create(meta)
|
||||
// Bind this state to the live session so a later DIFFERENT session reusing
|
||||
// the id is detected as a collision (case 1) rather than silently no-opped.
|
||||
|
||||
Reference in New Issue
Block a user