2026-07-28 13:55:59 +08:00
|
|
|
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
import { existsSync } from 'node:fs'
|
2026-07-23 13:56:56 +08:00
|
|
|
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
import { tmpdir } from 'node:os'
|
2026-07-17 10:15:19 +08:00
|
|
|
import { dirname, join } from 'node:path'
|
2026-07-23 22:08:58 +08:00
|
|
|
import { DatabaseSync } from 'node:sqlite'
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
2026-06-22 10:35:59 +08:00
|
|
|
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
2026-07-24 10:35:09 +08:00
|
|
|
import {
|
|
|
|
|
openDatabase,
|
|
|
|
|
rowToEvent,
|
|
|
|
|
rowToMeta,
|
|
|
|
|
scanRows,
|
|
|
|
|
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
|
|
|
|
|
type EventRow,
|
|
|
|
|
} from '../src/schema.ts'
|
2026-06-24 17:45:48 +08:00
|
|
|
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
|
|
|
|
|
const dirs: string[] = []
|
|
|
|
|
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
|
|
|
|
|
2026-07-19 22:13:50 +08:00
|
|
|
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
|
2026-07-15 11:31:55 +08:00
|
|
|
try {
|
|
|
|
|
await promise
|
|
|
|
|
} catch (error) {
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(error).toBeInstanceOf(Error)
|
|
|
|
|
expect((error as Error).message).toMatch(message)
|
2026-07-15 11:31:55 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 22:13:50 +08:00
|
|
|
throw new Error('expected flush to reject')
|
2026-07-15 11:31:55 +08:00
|
|
|
}
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
async function freshDbPath(): Promise<string> {
|
|
|
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
|
|
|
|
|
dirs.push(dir)
|
|
|
|
|
return join(dir, 'sessions.db')
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 22:23:36 +08:00
|
|
|
/** A context with the session store + SQLite backend, plus a teardown. */
|
|
|
|
|
async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise<void> }> {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
return { ctx, dispose: () => fiber.dispose() }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 23:27:00 +08:00
|
|
|
// Run the same backend-agnostic contract as JSONL to pin identical semantics.
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
runPersistenceContract('sqlite', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
|
|
|
return {
|
|
|
|
|
persistence: ctx.sessionPersistence,
|
|
|
|
|
dispose: async () => { await fiber.dispose() },
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-13 23:27:00 +08:00
|
|
|
// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
|
|
|
|
|
// JSON past the committed seq, exercising coordinator repair against real database rows.
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
|
|
|
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
|
|
|
|
|
const path = join(dir, 'sessions.db')
|
|
|
|
|
return {
|
|
|
|
|
mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
|
|
|
|
|
corruptTail: async (id) => {
|
|
|
|
|
// A row past the committed region whose `data` does not parse: scanRows
|
|
|
|
|
// bounds the preserved prefix at it and returns its seq as tornFrom, which
|
|
|
|
|
// the backend surfaces to the coordinator as the tornMarker to delete from.
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const db = openDatabase(path, 'wal')
|
refactor(session-persistence): extract a shared write coordinator
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
2026-06-20 03:47:28 +08:00
|
|
|
const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?')
|
|
|
|
|
.get(id) as { n: number }).n
|
|
|
|
|
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
|
|
|
|
.run(id, next, 'assistant/chunk', 99, '{not valid json')
|
|
|
|
|
db.close()
|
|
|
|
|
},
|
|
|
|
|
cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
describe('scanRows', () => {
|
2026-07-12 03:36:43 +08:00
|
|
|
// scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
|
2026-07-13 23:27:00 +08:00
|
|
|
// so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
|
|
|
|
|
// its nullable columns so the conversion remains faithful.
|
2026-06-16 22:56:17 +08:00
|
|
|
const rows = (events: SessionEvent[]): EventRow[] =>
|
2026-06-24 17:45:48 +08:00
|
|
|
events.map((e) => {
|
|
|
|
|
const se = e as SessionEvent<SurfaceEventType>
|
|
|
|
|
return {
|
|
|
|
|
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
|
|
|
|
|
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
|
|
|
|
|
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-06-16 22:56:17 +08:00
|
|
|
|
|
|
|
|
it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
|
|
|
|
|
const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
|
|
|
|
|
expect(preserved).toEqual(oneTurnLog())
|
|
|
|
|
expect(tornFrom).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('PRESERVES the real events of an interrupted turn after the last turn/end', () => {
|
|
|
|
|
// turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no
|
|
|
|
|
// close): all 8 rows are intact, so the whole prefix is preserved and there
|
|
|
|
|
// is no torn fragment to delete. (load() then synthesizes the closers.)
|
|
|
|
|
const withOpenTurn: SessionEvent[] = [
|
|
|
|
|
...oneTurnLog(),
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
2026-06-16 22:56:17 +08:00
|
|
|
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
]
|
2026-06-16 22:56:17 +08:00
|
|
|
const { preserved, tornFrom } = scanRows(rows(withOpenTurn))
|
|
|
|
|
expect(preserved.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
|
|
|
expect(tornFrom).toBeUndefined()
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('preserves the contiguous prefix and flags a torn tail at a seq gap', () => {
|
|
|
|
|
// A gap after seq 0 (no committed turn/end): seq 0 is the preserved
|
|
|
|
|
// interrupted-turn event; the gap bounds it and marks the torn fragment.
|
|
|
|
|
const gapped: SessionEvent[] = [
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
2026-06-16 22:56:17 +08:00
|
|
|
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
]
|
2026-06-16 22:56:17 +08:00
|
|
|
const { preserved, tornFrom } = scanRows(rows(gapped))
|
|
|
|
|
expect(preserved.map(e => e.seq)).toEqual([0])
|
|
|
|
|
expect(tornFrom).toBe(1)
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('an empty log preserves nothing and has no torn tail', () => {
|
|
|
|
|
expect(scanRows([])).toEqual({ preserved: [] })
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('throws on a seq gap inside the committed region (before the last turn/end)', () => {
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
const gapped: SessionEvent[] = [
|
|
|
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
|
|
|
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
|
|
|
|
|
{ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
|
|
|
]
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('throws on an unparsable row inside the committed region', () => {
|
|
|
|
|
const withCorruptCommitted: EventRow[] = [
|
2026-06-22 10:35:59 +08:00
|
|
|
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
|
|
|
|
|
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
|
2026-06-16 22:56:17 +08:00
|
|
|
]
|
|
|
|
|
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
|
|
|
|
|
const withCorruptTail: EventRow[] = [
|
|
|
|
|
...rows(oneTurnLog()),
|
2026-06-22 10:35:59 +08:00
|
|
|
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
|
2026-06-16 22:56:17 +08:00
|
|
|
]
|
|
|
|
|
const { preserved, tornFrom } = scanRows(withCorruptTail)
|
|
|
|
|
expect(preserved).toEqual(oneTurnLog())
|
|
|
|
|
expect(tornFrom).toBe(6)
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-24 10:35:09 +08:00
|
|
|
describe('rowToMeta', () => {
|
|
|
|
|
it('rejects fractional stored creation metadata', () => {
|
|
|
|
|
expect(() => rowToMeta({
|
|
|
|
|
id: 'fractional',
|
|
|
|
|
version: 0,
|
|
|
|
|
created_at: 1.5,
|
|
|
|
|
cwd: null,
|
|
|
|
|
parent_session: null,
|
|
|
|
|
seed_length: null,
|
|
|
|
|
incarnation: 'fractional',
|
|
|
|
|
revision: 1,
|
|
|
|
|
delegation_depth: null,
|
|
|
|
|
})).toThrow('stored session createdAt must be a non-negative safe integer')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
2026-07-13 23:56:10 +08:00
|
|
|
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('legacy-header-delta', '/legacy')
|
|
|
|
|
const db = openDatabase(path, 'wal')
|
2026-07-23 13:56:56 +08:00
|
|
|
db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
|
|
|
|
|
.run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta')
|
2026-07-13 23:56:10 +08:00
|
|
|
const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
|
|
|
|
insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
|
|
|
|
insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
|
|
|
|
|
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
const mounted = await backend(path)
|
|
|
|
|
await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
|
|
|
|
|
await mounted.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 12:32:44 +08:00
|
|
|
it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('legacy-header-fallback', '/legacy')
|
|
|
|
|
const db = openDatabase(path, 'wal')
|
2026-07-23 13:56:56 +08:00
|
|
|
db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
|
|
|
|
|
.run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-fallback')
|
2026-07-14 12:32:44 +08:00
|
|
|
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
|
|
|
|
.run(m.id, 0, 'request/header', 1, JSON.stringify({
|
|
|
|
|
header: { config: { model: 'legacy' } },
|
|
|
|
|
reason: 'fallback',
|
|
|
|
|
}))
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
const mounted = await backend(path)
|
|
|
|
|
await expect(mounted.ctx.sessionPersistence.load(m.id))
|
|
|
|
|
.rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
|
|
|
|
|
await mounted.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-10 20:52:27 +08:00
|
|
|
it('has no independent per-session log location', async () => {
|
|
|
|
|
const { ctx, dispose } = await backend()
|
|
|
|
|
expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined()
|
|
|
|
|
await dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('crash')
|
|
|
|
|
// Run 1: persist a complete turn, then a half-written second turn (no turn/end).
|
|
|
|
|
const ctx1 = new Context()
|
|
|
|
|
await ctx1.plugin(SessionStore)
|
|
|
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
await ctx1.sessionPersistence.create(m)
|
|
|
|
|
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
await ctx1.sessionPersistence.append(m.id, [
|
|
|
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
2026-06-16 22:56:17 +08:00
|
|
|
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
])
|
|
|
|
|
await fiber1.dispose()
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
// Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge
|
|
|
|
|
// — never truncated) and closes the orphaned turn with synthetic boundary
|
|
|
|
|
// events: step/end (the step was open) then turn/end {interrupted}.
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
const ctx2 = new Context()
|
|
|
|
|
await ctx2.plugin(SessionStore)
|
|
|
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
const loaded = await ctx2.sessionPersistence.load(m.id)
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(loaded.events.map(e => e.type)).toEqual([
|
|
|
|
|
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
|
|
|
|
|
'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
|
|
|
|
|
])
|
|
|
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
|
|
|
|
|
const last = loaded.events.at(-1)!
|
|
|
|
|
expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
// load durably closed the turn, so the next append continues at the balanced
|
|
|
|
|
// length (seq 10) and a reload round-trips identically.
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
await ctx2.sessionPersistence.append(m.id, [
|
2026-06-16 22:56:17 +08:00
|
|
|
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
|
|
|
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
])
|
|
|
|
|
const reloaded = await ctx2.sessionPersistence.load(m.id)
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
await fiber2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('load() durably closes the interrupted turn: the synthetic closers are on disk after load', async () => {
|
2026-06-16 00:05:25 +08:00
|
|
|
const path = await freshDbPath()
|
2026-06-16 22:56:17 +08:00
|
|
|
const m = meta('load-closes')
|
2026-06-16 00:05:25 +08:00
|
|
|
const b1 = await backend(path)
|
|
|
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
|
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
|
|
|
|
|
await b1.dispose()
|
2026-06-16 22:56:17 +08:00
|
|
|
// Hand-write an interrupted turn (turn/start seq 6, no turn/end).
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const db = openDatabase(path, 'wal')
|
2026-06-16 00:05:25 +08:00
|
|
|
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
|
|
|
|
.run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
const b2 = await backend(path)
|
|
|
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
2026-06-16 22:56:17 +08:00
|
|
|
// turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7).
|
|
|
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
|
|
|
expect(loaded.events.at(-1)!.type).toBe('turn/end')
|
|
|
|
|
// load() is mutating: the synthetic turn/end MUST be on disk so the stored log
|
|
|
|
|
// is balanced and the cursor is truthful (contract: load closes, not defers).
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const probe = openDatabase(path, 'wal')
|
2026-06-16 22:56:17 +08:00
|
|
|
const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[]
|
2026-06-16 00:05:25 +08:00
|
|
|
probe.close()
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
|
|
|
expect(stored.at(-1)!.type).toBe('turn/end')
|
2026-06-16 00:05:25 +08:00
|
|
|
await b2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
it('all-tail load: a session whose only turn never closed is preserved and closed on load', async () => {
|
2026-06-16 00:05:25 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('all-tail')
|
|
|
|
|
const b1 = await backend(path)
|
|
|
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
|
|
|
// A first turn that NEVER completed: turn/start + user/message, no turn/end.
|
|
|
|
|
await b1.ctx.sessionPersistence.append(m.id, [
|
|
|
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
2026-07-28 13:55:59 +08:00
|
|
|
{ type: 'user/message', seq: 1, time: 2, data: createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
|
|
|
|
}) },
|
2026-06-16 00:05:25 +08:00
|
|
|
])
|
|
|
|
|
await b1.dispose()
|
|
|
|
|
|
2026-06-16 22:56:17 +08:00
|
|
|
// A fresh backend loads it: the interrupted (only) turn's real events are
|
|
|
|
|
// preserved and closed with a synthetic turn/end {interrupted} — NOT
|
2026-06-21 02:17:27 +08:00
|
|
|
// truncated. The session was materialized, so list() reports it present.
|
2026-06-16 00:05:25 +08:00
|
|
|
const b2 = await backend(path)
|
|
|
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
|
|
|
|
|
expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } })
|
2026-06-16 20:35:01 +08:00
|
|
|
expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
|
2026-06-16 00:05:25 +08:00
|
|
|
await b2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
|
2026-06-16 00:05:25 +08:00
|
|
|
const path = await freshDbPath()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
|
2026-06-16 00:05:25 +08:00
|
|
|
// Bump user_version past what this build supports.
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const dbNewer = openDatabase(path, 'wal')
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
|
|
|
|
|
dbNewer.close()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
|
2026-07-15 12:32:18 +08:00
|
|
|
// The immediately preceding layout lacks the required store identity and is
|
|
|
|
|
// rejected rather than migrated (unreleased software, no backward-compat).
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
const olderPath = await freshDbPath()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
openDatabase(olderPath, 'wal').close()
|
|
|
|
|
const dbOlder = openDatabase(olderPath, 'wal')
|
2026-07-15 12:32:18 +08:00
|
|
|
dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
dbOlder.close()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
|
2026-06-16 00:05:25 +08:00
|
|
|
})
|
|
|
|
|
|
2026-07-24 10:35:09 +08:00
|
|
|
it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
|
2026-07-23 22:08:58 +08:00
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const legacy = new DatabaseSync(path)
|
|
|
|
|
legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
|
|
|
|
|
legacy.close()
|
|
|
|
|
|
2026-07-24 10:35:09 +08:00
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
|
2026-07-23 22:08:58 +08:00
|
|
|
|
|
|
|
|
const unchanged = new DatabaseSync(path)
|
|
|
|
|
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
|
|
|
|
expect(unchanged.prepare(
|
|
|
|
|
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'",
|
|
|
|
|
).get()).toEqual({ name: 'sessions' })
|
|
|
|
|
unchanged.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-24 11:11:45 +08:00
|
|
|
it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const unrelated = new DatabaseSync(path)
|
|
|
|
|
unrelated.exec('CREATE TABLE sqliteX (value TEXT)')
|
|
|
|
|
unrelated.exec("INSERT INTO sqliteX VALUES ('safe')")
|
|
|
|
|
unrelated.close()
|
|
|
|
|
|
|
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
|
|
|
|
|
|
|
|
|
|
const unchanged = new DatabaseSync(path)
|
|
|
|
|
expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
|
|
|
|
unchanged.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-24 10:35:09 +08:00
|
|
|
it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
|
|
|
|
|
const viewPath = await freshDbPath()
|
|
|
|
|
const viewOnly = new DatabaseSync(viewPath)
|
|
|
|
|
viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
|
|
|
|
|
viewOnly.close()
|
|
|
|
|
|
|
|
|
|
expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
|
|
|
|
|
const unchangedView = new DatabaseSync(viewPath)
|
|
|
|
|
expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
|
|
|
|
expect(unchangedView.prepare(
|
|
|
|
|
"SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
|
|
|
|
|
).get()).toEqual({ type: 'view' })
|
|
|
|
|
unchangedView.close()
|
|
|
|
|
|
|
|
|
|
const applicationPath = await freshDbPath()
|
|
|
|
|
const foreignApplication = new DatabaseSync(applicationPath)
|
|
|
|
|
foreignApplication.exec('PRAGMA application_id = 12345')
|
|
|
|
|
foreignApplication.close()
|
|
|
|
|
|
|
|
|
|
expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
|
|
|
|
|
const unchangedApplication = new DatabaseSync(applicationPath)
|
|
|
|
|
expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
|
|
|
|
|
expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
|
|
|
|
|
expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
|
|
|
|
unchangedApplication.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a current-version database with a foreign application identity', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const foreign = new DatabaseSync(path)
|
|
|
|
|
foreign.exec('PRAGMA application_id = 12345')
|
|
|
|
|
foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
|
|
|
foreign.close()
|
|
|
|
|
|
|
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
|
|
|
|
|
|
|
|
|
|
const unchanged = new DatabaseSync(path)
|
|
|
|
|
expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
|
|
|
|
unchanged.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rolls back schema objects and identity stamps when initialization fails', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const conflicting = new DatabaseSync(path)
|
|
|
|
|
conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
|
|
|
|
|
conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
|
|
|
|
conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
|
|
|
|
|
conflicting.close()
|
|
|
|
|
|
|
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow()
|
|
|
|
|
|
|
|
|
|
const unchanged = new DatabaseSync(path)
|
|
|
|
|
expect(unchanged.prepare(
|
|
|
|
|
"SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
|
|
|
|
|
).get()).toEqual({ type: 'view' })
|
|
|
|
|
expect(unchanged.prepare(
|
|
|
|
|
"SELECT type FROM sqlite_schema WHERE name = 'sessions'",
|
|
|
|
|
).get()).toBeUndefined()
|
|
|
|
|
expect(unchanged.prepare(
|
|
|
|
|
"SELECT type FROM sqlite_schema WHERE name = 'events'",
|
|
|
|
|
).get()).toBeUndefined()
|
|
|
|
|
expect(unchanged.prepare('PRAGMA application_id').get())
|
|
|
|
|
.toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
|
|
|
|
|
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
|
2026-07-24 11:20:47 +08:00
|
|
|
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
|
2026-07-24 10:35:09 +08:00
|
|
|
unchanged.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('stamps the persistence application identity with the schema version', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
openDatabase(path, 'wal').close()
|
|
|
|
|
|
|
|
|
|
const db = new DatabaseSync(path)
|
|
|
|
|
expect(db.prepare('PRAGMA application_id').get())
|
|
|
|
|
.toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
|
|
|
|
|
expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
|
|
|
|
|
db.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-24 17:45:48 +08:00
|
|
|
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
|
2026-07-15 16:17:59 +08:00
|
|
|
// Version 3 identified two incompatible sibling layouts, so it is always rejected.
|
2026-06-24 17:45:48 +08:00
|
|
|
const path = await freshDbPath()
|
2026-07-15 12:10:24 +08:00
|
|
|
openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const db = openDatabase(path, 'wal')
|
2026-06-24 17:45:48 +08:00
|
|
|
db.exec('PRAGMA user_version = 3')
|
|
|
|
|
db.close()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/)
|
2026-06-24 17:45:48 +08:00
|
|
|
})
|
|
|
|
|
|
2026-06-15 22:23:36 +08:00
|
|
|
it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('corrupt-tail')
|
|
|
|
|
const b1 = await backend(path)
|
|
|
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
|
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
|
|
|
|
|
await b1.dispose()
|
|
|
|
|
|
2026-07-13 23:27:00 +08:00
|
|
|
// A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
|
|
|
|
|
// from seq/type columns without parsing the tail, preserves the committed prefix, and load
|
|
|
|
|
// deletes the row; invalid JSON inside the committed region would remain fatal.
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
const db = openDatabase(path, 'wal')
|
2026-06-15 22:23:36 +08:00
|
|
|
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
|
|
|
|
|
.run(m.id, 'turn/start', '{not valid json')
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
const b2 = await backend(path)
|
|
|
|
|
const loaded = await b2.ctx.sessionPersistence.load(m.id)
|
2026-06-16 22:56:17 +08:00
|
|
|
expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
|
|
|
|
|
// load physically deleted the corrupt tail row, so a fresh append continues.
|
2026-06-15 22:23:36 +08:00
|
|
|
await b2.ctx.sessionPersistence.append(m.id, [
|
|
|
|
|
{ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
|
|
|
{ type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
|
|
|
|
|
])
|
|
|
|
|
const reloaded = await b2.ctx.sessionPersistence.load(m.id)
|
|
|
|
|
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
|
|
|
await b2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
|
|
|
const m = meta('rollback')
|
|
|
|
|
await ctx.sessionPersistence.create(m)
|
|
|
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
|
|
|
|
|
|
|
|
|
|
// A batch that re-states an already-stored seq must be rejected and leave
|
|
|
|
|
// the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
|
|
|
|
|
// inside the transaction → ROLLBACK).
|
|
|
|
|
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
|
|
|
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
|
|
|
expect(loaded.events).toEqual(oneTurnLog()) // unchanged
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists across separate backend instances over the same file', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('persist', '/proj')
|
|
|
|
|
const ctx1 = new Context()
|
|
|
|
|
await ctx1.plugin(SessionStore)
|
|
|
|
|
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
await ctx1.sessionPersistence.create(m)
|
|
|
|
|
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
await fiber1.dispose()
|
|
|
|
|
|
|
|
|
|
const ctx2 = new Context()
|
|
|
|
|
await ctx2.plugin(SessionStore)
|
|
|
|
|
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
|
|
|
|
|
expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
|
|
|
|
|
const loaded = await ctx2.sessionPersistence.load(m.id)
|
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.
2026-06-20 01:03:57 +08:00
|
|
|
expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
expect(loaded.events).toEqual(oneTurnLog())
|
|
|
|
|
await fiber2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-15 12:32:18 +08:00
|
|
|
it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => {
|
|
|
|
|
const pathA = await freshDbPath()
|
|
|
|
|
const pathB = await freshDbPath()
|
|
|
|
|
const m = meta('revision-source')
|
|
|
|
|
const a = await backend(pathA)
|
|
|
|
|
await a.ctx.sessionPersistence.create(m)
|
|
|
|
|
await a.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision
|
|
|
|
|
await a.dispose()
|
|
|
|
|
|
|
|
|
|
const probeA = openDatabase(pathA, 'wal')
|
|
|
|
|
const storeIdA = (probeA.prepare(
|
|
|
|
|
'SELECT store_id FROM persistence_state WHERE singleton = 1',
|
|
|
|
|
).get() as { store_id: string }).store_id
|
|
|
|
|
probeA.close()
|
|
|
|
|
|
|
|
|
|
const aliasA = `${pathA}.alias`
|
|
|
|
|
await symlink(pathA, aliasA)
|
|
|
|
|
const reopenedA = await backend(aliasA)
|
|
|
|
|
expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA)
|
|
|
|
|
await reopenedA.dispose()
|
|
|
|
|
|
|
|
|
|
const b = await backend(pathB)
|
|
|
|
|
await b.ctx.sessionPersistence.create(m)
|
|
|
|
|
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision
|
|
|
|
|
const probeB = openDatabase(pathB, 'wal')
|
|
|
|
|
const storeIdB = (probeB.prepare(
|
|
|
|
|
'SELECT store_id FROM persistence_state WHERE singleton = 1',
|
|
|
|
|
).get() as { store_id: string }).store_id
|
|
|
|
|
probeB.close()
|
|
|
|
|
expect(storeIdB).not.toBe(storeIdA)
|
|
|
|
|
expect(revisionB).not.toBe(revisionA)
|
|
|
|
|
expect(String(revisionA)).toMatch(/:revision:1$/)
|
|
|
|
|
expect(String(revisionB)).toMatch(/:revision:1$/)
|
|
|
|
|
await b.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 09:39:39 +08:00
|
|
|
it('changes revisions when a deleted session id is materialized again in the same database', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('recreated-revision')
|
|
|
|
|
const first = await backend(path)
|
|
|
|
|
await first.ctx.sessionPersistence.create(m)
|
|
|
|
|
await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision
|
|
|
|
|
await first.dispose()
|
|
|
|
|
|
|
|
|
|
const cleanup = openDatabase(path, 'wal')
|
|
|
|
|
cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
|
|
|
|
|
cleanup.close()
|
|
|
|
|
|
|
|
|
|
const second = await backend(path)
|
|
|
|
|
await second.ctx.sessionPersistence.create(m)
|
|
|
|
|
await second.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision
|
|
|
|
|
expect(after).not.toBe(before)
|
|
|
|
|
expect(String(before)).toMatch(/:revision:1$/)
|
|
|
|
|
expect(String(after)).toMatch(/:revision:1$/)
|
|
|
|
|
await second.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-24 21:27:07 +08:00
|
|
|
it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
|
|
|
|
|
const b = await backend()
|
|
|
|
|
const internals = b.ctx.sessionPersistence as unknown as { ready: Promise<void> }
|
|
|
|
|
const originalReady = internals.ready
|
|
|
|
|
const readiness = Promise.withResolvers<undefined>()
|
|
|
|
|
internals.ready = readiness.promise
|
|
|
|
|
const reason = new Error('SQLite snapshot readiness cancelled')
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
|
|
|
|
|
let settled = false
|
|
|
|
|
void pending.then(
|
|
|
|
|
() => { settled = true },
|
|
|
|
|
() => { settled = true },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
controller.abort(reason)
|
|
|
|
|
await Promise.resolve()
|
|
|
|
|
expect(settled).toBe(false)
|
|
|
|
|
|
|
|
|
|
readiness.resolve(undefined)
|
|
|
|
|
await expect(pending).rejects.toBe(reason)
|
|
|
|
|
internals.ready = originalReady
|
|
|
|
|
await b.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
it('exposes the schema version constant', () => {
|
2026-07-28 21:40:51 +08:00
|
|
|
expect(SCHEMA_VERSION).toBe(12)
|
2026-07-15 12:10:24 +08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps the revision stable for an empty repair hook', async () => {
|
|
|
|
|
const b = await backend()
|
|
|
|
|
const m = meta('empty-repair')
|
|
|
|
|
await b.ctx.sessionPersistence.create(m)
|
|
|
|
|
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
const before = await b.ctx.sessionPersistence.listSnapshots()
|
|
|
|
|
await (b.ctx.sessionPersistence as SessionPersistenceSqlite).commitRepair(m, undefined, [])
|
|
|
|
|
expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
|
|
|
|
|
await b.dispose()
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('SessionPersistenceSqlite: edge cases', () => {
|
2026-07-15 12:32:18 +08:00
|
|
|
it('rejects and closes a current-schema database with an invalid store identity', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const db = openDatabase(path, 'wal')
|
|
|
|
|
db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1")
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
const b = await backend(path)
|
|
|
|
|
await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/)
|
|
|
|
|
await expect(b.dispose()).resolves.toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 11:37:41 +08:00
|
|
|
it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => {
|
2026-07-17 10:15:19 +08:00
|
|
|
if (process.platform === 'win32') return
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const dir = dirname(path)
|
|
|
|
|
await chmod(dir, 0o755)
|
|
|
|
|
|
|
|
|
|
const b = await backend(path)
|
|
|
|
|
await b.ctx.sessionPersistence.list()
|
|
|
|
|
|
|
|
|
|
expect((await stat(dir)).mode & 0o777).toBe(0o755)
|
|
|
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
|
|
|
expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
|
|
|
|
|
expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
|
|
|
|
|
await b.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 12:12:16 +08:00
|
|
|
it('creates a persistent rollback journal with owner-only mode', async () => {
|
|
|
|
|
if (process.platform === 'win32') return
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' })
|
|
|
|
|
const m = meta('persist-permissions')
|
|
|
|
|
|
|
|
|
|
await ctx.sessionPersistence.create(m)
|
|
|
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
|
|
|
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
|
|
|
expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 10:15:19 +08:00
|
|
|
it('preserves the mode of an existing database file', async () => {
|
|
|
|
|
if (process.platform === 'win32') return
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
await writeFile(path, '', { mode: 0o644 })
|
|
|
|
|
await chmod(path, 0o644)
|
|
|
|
|
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' })
|
|
|
|
|
await ctx.sessionPersistence.list()
|
|
|
|
|
|
|
|
|
|
expect((await stat(path)).mode & 0o777).toBe(0o644)
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 11:37:41 +08:00
|
|
|
it('surfaces an invalid database path during pre-creation', async () => {
|
2026-07-17 10:15:19 +08:00
|
|
|
const path = await freshDbPath()
|
2026-07-17 10:47:32 +08:00
|
|
|
const b = await backend(`${path}\0`)
|
|
|
|
|
|
|
|
|
|
await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
|
|
|
|
|
await b.dispose()
|
2026-07-17 10:15:19 +08:00
|
|
|
})
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
const m = meta('rollback-insert')
|
|
|
|
|
const b1 = await backend(path)
|
|
|
|
|
await b1.ctx.sessionPersistence.create(m)
|
|
|
|
|
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
|
|
|
|
|
|
// A SECOND backend over the same file loads the session first, so it adopts
|
|
|
|
|
// cursor 6 (the committed length) into its OWN in-memory state.
|
|
|
|
|
const b2 = await backend(path)
|
|
|
|
|
await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
|
|
|
|
|
const turn2: SessionEvent[] = [
|
|
|
|
|
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
|
|
|
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
|
|
|
|
]
|
|
|
|
|
// b1 commits seq 6..7 first.
|
|
|
|
|
await b1.ctx.sessionPersistence.append(m.id, turn2)
|
|
|
|
|
// b2 still thinks its cursor is 6, so this batch passes the contiguity check
|
|
|
|
|
// but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
|
|
|
|
|
// mid-transaction → ROLLBACK + rethrow.
|
|
|
|
|
await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
|
|
|
|
|
// b1's turn is intact; b2's rolled-back attempt left nothing extra.
|
|
|
|
|
const loaded = await b1.ctx.sessionPersistence.load(m.id)
|
|
|
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
|
|
|
await b1.dispose()
|
|
|
|
|
await b2.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => {
|
|
|
|
|
// :memory: databases always report journal_mode=memory, so probe file DBs.
|
|
|
|
|
const walPath = await freshDbPath()
|
|
|
|
|
const bWal = await backend(walPath)
|
|
|
|
|
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
|
2026-07-05 17:47:24 +08:00
|
|
|
const probe = openDatabase(walPath, 'wal')
|
|
|
|
|
expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
|
|
|
|
|
probe.close()
|
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
|
|
|
await bWal.dispose()
|
|
|
|
|
|
|
|
|
|
const deletePath = await freshDbPath()
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: deletePath, journalMode: 'delete' })
|
|
|
|
|
await ctx.sessionPersistence.create(meta('jm-delete'))
|
|
|
|
|
// Probe through a second connection: journal_mode=delete is a per-database
|
|
|
|
|
// property only insofar as no WAL files exist — assert the world, not the
|
|
|
|
|
// backend's self-report (no -wal sidecar after writes in delete mode).
|
|
|
|
|
const db = openDatabase(deletePath, 'delete')
|
|
|
|
|
expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete')
|
|
|
|
|
db.close()
|
|
|
|
|
expect(existsSync(`${deletePath}-wal`)).toBe(false)
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
|
|
|
|
|
const path = await freshDbPath()
|
|
|
|
|
// Instance 1 materializes a session and disposes.
|
|
|
|
|
const b1 = await backend(path)
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
|
2026-06-24 17:45:48 +08:00
|
|
|
appendLog(s1, oneTurnLog())
|
2026-07-19 22:13:50 +08:00
|
|
|
await b1.ctx.sessions.flush(s1)
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
await b1.dispose()
|
|
|
|
|
|
|
|
|
|
// A fresh context with an UNRELATED live session reusing the id meets a
|
|
|
|
|
// materialized row that is NOT a prefix of its events → reject.
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
let session!: Session
|
|
|
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
session = inner.sessions.create(SessionId('hmr-collide'))
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
}, { inject: ['sessions'] }))
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
await ctx.plugin(SessionPersistenceSqlite, { path })
|
2026-07-19 22:13:50 +08:00
|
|
|
await expectFlushError(ctx.sessions.flush(session), /id collision/)
|
feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.
It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
the first append (a never-appended session is absent from has()/list()
via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
complete turn/end and deletes the uncommitted tail; a seq gap in the
committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
a concurrent writer) rolls back entirely, keeping the cursor truthful.
Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).
Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
|
|
|
await ctx.fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-06-22 10:35:59 +08:00
|
|
|
|
|
|
|
|
describe('surface field round-trip', () => {
|
|
|
|
|
it('rowToEvent parses surface fields from EventRow columns', () => {
|
|
|
|
|
const row: EventRow = {
|
|
|
|
|
seq: 0, type: 'assistant/message', time: 1,
|
|
|
|
|
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
|
|
|
|
source_event_seqs: JSON.stringify([3, 5]),
|
|
|
|
|
surface_op: JSON.stringify('append'),
|
|
|
|
|
}
|
|
|
|
|
const event = rowToEvent(row)
|
|
|
|
|
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
|
|
|
|
|
expect((event as SurfaceEvent).surfaceOp).toBe('append')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rowToEvent handles replace surfaceOp object', () => {
|
|
|
|
|
const row: EventRow = {
|
|
|
|
|
seq: 0, type: 'assistant/message', time: 1,
|
|
|
|
|
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
|
|
|
|
|
source_event_seqs: JSON.stringify([0, 1]),
|
|
|
|
|
surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
|
|
|
|
|
}
|
|
|
|
|
const event = rowToEvent(row)
|
|
|
|
|
expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
|
|
|
|
|
expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('scanRows with surface columns reconstructs events with surface fields', () => {
|
|
|
|
|
const rows: EventRow[] = [
|
|
|
|
|
{ seq: 0, type: 'user/message', time: 1,
|
|
|
|
|
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
|
|
|
|
|
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
|
|
|
|
|
{ seq: 1, type: 'turn/end', time: 2,
|
|
|
|
|
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
|
|
|
|
|
source_event_seqs: null, surface_op: null },
|
|
|
|
|
]
|
|
|
|
|
const { preserved } = scanRows(rows)
|
|
|
|
|
expect(preserved).toHaveLength(2)
|
|
|
|
|
expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
|
|
|
|
expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
|
|
|
|
expect((preserved[1] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('append and load round-trips surface fields through SQLite', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
|
|
|
const session = ctx.sessions.create(SessionId('roundtrip-surface'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('step/start', { turn: 1, step: 1 })
|
2026-07-28 13:55:59 +08:00
|
|
|
session.append('user/message', createUserMessage({
|
|
|
|
|
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
|
|
|
|
}), { surfaceOp: 'append' })
|
|
|
|
|
session.append('assistant/message', {
|
|
|
|
|
turn: 1, step: 1,
|
|
|
|
|
message: createMessage({
|
|
|
|
|
role: 'assistant',
|
|
|
|
|
content: [],
|
|
|
|
|
source: {
|
|
|
|
|
kind: 'model',
|
|
|
|
|
...{ provider: 'mock', model: 'mock' },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
2026-07-19 22:13:50 +08:00
|
|
|
session.append('step/end', { turn: 1, step: 1 })
|
2026-06-22 10:35:59 +08:00
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
2026-07-19 22:13:50 +08:00
|
|
|
await ctx.sessions.flush(session)
|
2026-06-22 10:35:59 +08:00
|
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
|
2026-07-19 22:13:50 +08:00
|
|
|
expect(loaded.events).toHaveLength(6)
|
|
|
|
|
const um = loaded.events[2]!
|
2026-06-22 10:35:59 +08:00
|
|
|
expect((um as SurfaceEvent).surfaceOp).toBe('append')
|
|
|
|
|
expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
2026-07-19 22:13:50 +08:00
|
|
|
const am = loaded.events[3]!
|
2026-06-22 10:35:59 +08:00
|
|
|
expect((am as SurfaceEvent).surfaceOp).toBe('append')
|
2026-07-19 22:13:50 +08:00
|
|
|
expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2])
|
2026-06-22 10:35:59 +08:00
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
|
|
|
|
|
const ctx = new Context()
|
|
|
|
|
await ctx.plugin(SessionStore)
|
|
|
|
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
|
|
|
|
const session = ctx.sessions.create(SessionId('surface-noseq'))
|
|
|
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
2026-07-28 13:55:59 +08:00
|
|
|
session.append('steering/message', {
|
|
|
|
|
turn: 1,
|
|
|
|
|
message: createUserMessage({
|
|
|
|
|
content: [],
|
|
|
|
|
source: { kind: 'user' },
|
|
|
|
|
}),
|
|
|
|
|
}, { surfaceOp: 'append' })
|
2026-06-22 10:35:59 +08:00
|
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
2026-07-19 22:13:50 +08:00
|
|
|
await ctx.sessions.flush(session)
|
2026-06-22 10:35:59 +08:00
|
|
|
const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
|
|
|
|
|
expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
|
|
|
|
|
expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
|
|
|
|
|
await fiber.dispose()
|
|
|
|
|
})
|
|
|
|
|
})
|