Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine

# Conflicts:
#	docs/cordis-catalog/services.md
This commit is contained in:
_Kerman
2026-07-24 21:41:02 +08:00
41 changed files with 922 additions and 144 deletions

View File

@@ -120,8 +120,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
if (record.id !== id) {
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
}
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
throw new Error('session header createdAt must be a finite number')
if (typeof record.createdAt !== 'number'
|| !Number.isSafeInteger(record.createdAt)
|| record.createdAt < 0) {
throw new Error('session header createdAt must be a non-negative safe integer')
}
if (record.cwd !== undefined) {
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')

View File

@@ -36,7 +36,7 @@ export interface SessionHeader {
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
readonly id: SessionId
/** Unix epoch milliseconds when the session was created. */
/** Non-negative safe-integer Unix epoch milliseconds when the session was created. */
readonly createdAt: number
/** Absolute working directory the session was created in (if any). */
readonly cwd?: string

View File

@@ -721,7 +721,7 @@ describe('Session', () => {
{ header: 1, error: /not a plain JSON record/ },
{ header: null, error: /not a plain JSON record/ },
{ header: { ...base, version: 1 }, error: /header version/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
@@ -926,7 +926,7 @@ describe('SessionStore', () => {
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
})
@@ -965,7 +965,10 @@ describe('SessionStore', () => {
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },

View File

@@ -6,9 +6,9 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
- `get_goal()` returns the current goal or `null`, including the compare-and-set id/revision, durable phase, admitted/capped goal rounds, any blocker reason, and current process-local activation.
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.

View File

@@ -130,6 +130,16 @@ function resolveConfig(config: Config): ResolvedConfig {
return { blockedAfterConsecutiveRounds: blockedAfter }
}
/** Whether optional text is meaningful rather than a strict-schema empty filler. */
function hasText(value: string | undefined): value is string {
return value !== undefined && value !== ''
}
/** Whether an optional round cap is meaningful rather than a strict-schema zero filler. */
function hasRoundCap(value: number | undefined): value is number {
return value !== undefined && value !== 0
}
/** Build the exact compare-and-set ref from model arguments. */
function goalRef(goalId: string, revision: number): GoalRef {
if (goalId.length === 0 || goalId !== goalId.trim()
@@ -247,12 +257,12 @@ export function apply(ctx: Context, config: Config): void {
const execution = goalToolExecution(ctx, exec)
const ref = goalRef(args.goal_id, args.revision)
const replacements = {
...args.objective === undefined ? {} : { objective: args.objective },
...args.max_goal_rounds === undefined ? {} : { maxGoalRounds: args.max_goal_rounds },
...hasText(args.objective) ? { objective: args.objective } : {},
...hasRoundCap(args.max_goal_rounds) ? { maxGoalRounds: args.max_goal_rounds } : {},
}
if (args.action === 'edit') {
requireDirectHuman(ctx, execution)
if (args.blocked_reason !== undefined) {
if (hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
const goal = ctx.goals.edit(execution.agent, ref, replacements)
@@ -260,7 +270,7 @@ export function apply(ctx: Context, config: Config): void {
}
if (args.action === 'pause' || args.action === 'resume') {
requireDirectHuman(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined || args.blocked_reason !== undefined) {
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds) || hasText(args.blocked_reason)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit; blocked_reason is valid only with action blocked',
'GOAL_TOOL_INVALID_UPDATE',
@@ -272,13 +282,13 @@ export function apply(ctx: Context, config: Config): void {
return Promise.resolve(goalValue(goal))
}
const authority = completionAuthority(ctx, execution)
if (args.objective !== undefined || args.max_goal_rounds !== undefined) {
if (hasText(args.objective) || hasRoundCap(args.max_goal_rounds)) {
throw new HarnessError(
'objective and max_goal_rounds are valid only with action edit',
'GOAL_TOOL_INVALID_UPDATE',
)
}
if (args.action === 'complete' && args.blocked_reason !== undefined) {
if (args.action === 'complete' && hasText(args.blocked_reason)) {
throw new HarnessError('blocked_reason is valid only with action blocked', 'GOAL_TOOL_INVALID_UPDATE')
}
if (args.action === 'blocked'
@@ -305,7 +315,11 @@ export function apply(ctx: Context, config: Config): void {
presentCall: args => present(
`${args.action === 'blocked' ? 'Mark' : args.action.charAt(0).toUpperCase() + args.action.slice(1)} goal`,
'other',
args.blocked_reason ?? args.objective ?? args.goal_id,
hasText(args.blocked_reason)
? args.blocked_reason
: hasText(args.objective)
? args.objective
: hasRoundCap(args.max_goal_rounds) ? args.max_goal_rounds : args.goal_id,
),
}))
}

View File

@@ -144,8 +144,17 @@ describe('goal tool registration and presentation', () => {
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'blocked', blocked_reason: 'Waiting for a human choice.',
})).toEqual({ card: 'generic', title: 'Mark goal', kind: 'other', rawInput: 'Waiting for a human choice.' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: 'ship', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 'ship' })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'edit',
objective: '', max_goal_rounds: 8, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Edit goal', kind: 'other', rawInput: 8 })
expect(ctx.tools.get('update_goal')?.presentCall?.({
goal_id: 'goal-1', revision: 2, action: 'resume',
objective: '', max_goal_rounds: 0, blocked_reason: '',
})).toEqual({ card: 'generic', title: 'Resume goal', kind: 'other', rawInput: 'goal-1' })
expect(ctx.tools.get('update_goal')?.presentCall?.({ wrong: true })).toBeUndefined()
})
@@ -425,6 +434,77 @@ describe('goal tool state transitions', () => {
expect(malformedRef.error?.info?.code).toBe('GOAL_TOOL_INVALID_UPDATE')
})
it('accepts only empty fillers in fields unused by the selected action', async () => {
const { ctx, root } = await harness()
openTurn(root, { kind: 'user' })
let goal = ctx.goals.create(root.agent, { objective: 'valid' })
const edited = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'edit',
objective: 'edited',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(edited)).toMatchObject({ objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const capped = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'edit',
objective: '',
max_goal_rounds: 8,
blocked_reason: '',
}, root.agent)
expect(resultGoal(capped)).toMatchObject({ objective: 'edited', maxGoalRounds: 8 })
goal = ctx.goals.get(root.agent)!
const paused = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'pause',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused', objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const resumed = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'resume',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(resumed)).toMatchObject({ phase: 'active', objective: 'edited' })
goal = ctx.goals.get(root.agent)!
const blocked = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'blocked',
objective: '',
max_goal_rounds: 0,
blocked_reason: 'actual blocker',
}, root.agent)
expect(resultGoal(blocked)).toMatchObject({ phase: 'blocked' })
goal = ctx.goals.resume(root.agent, { id: goal.id, revision: goal.revision + 1 })
const complete = await execute(ctx, 'update_goal', {
goal_id: goal.id,
revision: goal.revision,
action: 'complete',
objective: '',
max_goal_rounds: 0,
blocked_reason: '',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete', objective: 'edited' })
})
it('allows exact goal rounds to complete but not edit or pause', async () => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })

View File

@@ -84,6 +84,9 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& typeof (value as { version?: unknown }).version === 'number'
&& typeof (value as { id?: unknown }).id === 'string'
&& typeof (value as { createdAt?: unknown }).createdAt === 'number'
&& Number.isSafeInteger((value as { createdAt: number }).createdAt)
&& (value as { createdAt: number }).createdAt >= 0
&& !Object.is((value as { createdAt: number }).createdAt, -0)
&& typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
&& (value as { delegationDepth: number }).delegationDepth >= 0

View File

@@ -559,6 +559,26 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
})
it.each([
['fractional', 1.5],
['negative', -1],
['unsafe', Number.MAX_SAFE_INTEGER + 1],
])('rejects a session header with a %s createdAt', (_label, createdAt) => {
const log = JSON.stringify({
type: 'session',
version: 0,
id: 'invalid-created-at',
createdAt,
delegationDepth: 0,
}) + '\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
})
it('rejects a session header with negative-zero createdAt', () => {
const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
})
it.each([
['missing', undefined],
['a string', '1'],

View File

@@ -8,9 +8,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
## Storage model
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations.
On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
@@ -55,5 +55,5 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).

View File

@@ -17,7 +17,10 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 8
export const SCHEMA_VERSION = 10
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -63,9 +66,10 @@ export interface EventRow {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
/**
* Open the database and apply its schema and pragmas. A zero `user_version` is
* stamped with {@link SCHEMA_VERSION}; every other non-current version rejects
* rather than being migrated in place.
* Open the database and apply its schema and pragmas. An empty database with a
* zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
* unversioned database and every other non-current version reject rather than
* being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and all three tables ensured.
@@ -83,51 +87,81 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
db.exec('PRAGMA foreign_keys = ON')
let began = false
try {
db.exec('BEGIN IMMEDIATE')
began = true
// Validate while holding the write lock so no other connection can change
// schema ownership between inspection and initialization.
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
const { count: userObjectCount } = db.prepare(
"SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
).get() as { count: number }
if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
}
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
}
if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
throw new Error(
`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
)
}
db.exec(`
CREATE TABLE IF NOT EXISTS persistence_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
store_id TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS events (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
time INTEGER NOT NULL,
data TEXT NOT NULL,
source_event_seqs TEXT,
surface_op TEXT,
PRIMARY KEY (session_id, seq)
) STRICT
`)
db.prepare(
'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
).run(randomUUID())
if (onDisk === 0) {
db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
db.exec('COMMIT')
began = false
} catch (error: unknown) {
/* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
if (began) {
/* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
try {
db.exec('ROLLBACK')
} catch {
// The original SQLite failure remains the actionable cause.
}
}
throw error
}
// The validated union is safe to interpolate into a non-bindable PRAGMA.
// Apply it only after ownership validation and initialization commit.
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
// `PRAGMA user_version` always returns exactly one row { user_version }.
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
}
if (onDisk === 0) {
// Stamp fresh or pre-versioning databases.
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS persistence_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
store_id TEXT NOT NULL
) STRICT
`)
db.prepare(
'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
).run(randomUUID())
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS events (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
time INTEGER NOT NULL,
data TEXT NOT NULL,
source_event_seqs TEXT,
surface_op TEXT,
PRIMARY KEY (session_id, seq)
) STRICT
`)
}
/**
@@ -136,6 +170,9 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
* @returns the header, `NULL` columns mapped to omitted optional fields.
*/
export function rowToMeta(row: SessionRow): SessionHeader {
if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
throw new Error('stored session createdAt must be a non-negative safe integer')
}
return {
version: row.version,
id: row.id as SessionId,

View File

@@ -4,10 +4,18 @@ import { existsSync } from 'node:fs'
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import {
openDatabase,
rowToEvent,
rowToMeta,
scanRows,
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
type EventRow,
} from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -150,6 +158,22 @@ describe('scanRows', () => {
})
})
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')
})
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const path = await freshDbPath()
@@ -304,6 +328,121 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
})
it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
const path = await freshDbPath()
const legacy = new DatabaseSync(path)
legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
legacy.close()
expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
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()
})
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()
})
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 })
expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
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()
})
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
// Version 3 identified two incompatible sibling layouts, so it is always rejected.
const path = await freshDbPath()
@@ -442,7 +581,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(8)
expect(SCHEMA_VERSION).toBe(10)
})
it('keeps the revision stable for an empty repair hook', async () => {

View File

@@ -180,6 +180,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) {
return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer'))
}
return this.serialize(snapshot.id, () => this.createCore(snapshot))
}

View File

@@ -84,6 +84,22 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('rejects a fractional creation timestamp without reserving its session id', async () => {
const { persistence, dispose } = await make()
try {
const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
await expect(persistence.create(m))
.rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
const valid = meta('fractional-created-at')
await persistence.create(valid)
await persistence.append(valid.id, oneTurnLog())
expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
} finally {
await dispose()
}
})
it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => {
const { persistence, dispose } = await make()
try {

View File

@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 3
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 5
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -78,7 +78,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
function listUserTables(db: DatabaseSync): string[] {
const rows = db.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' ORDER BY name",
).all() as Array<{ name: string }>
return rows.map(row => row.name)
}

View File

@@ -1148,6 +1148,26 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' })
stillForeign.close()
const wildcardPath = await temporaryPath('sqlite-wildcard.db')
const wildcard = new DatabaseSync(wildcardPath)
wildcard.exec('PRAGMA journal_mode = WAL')
wildcard.exec('CREATE TABLE sqliteX(value TEXT)')
wildcard.exec("INSERT INTO sqliteX VALUES ('safe')")
wildcard.close()
const wildcardCtx = new Context()
await wildcardCtx.plugin(SessionStore)
await expect(wildcardCtx.plugin(SessionQuerySqlite, {
path: wildcardPath,
journalMode: 'delete',
})).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(wildcardCtx.sessionQuery).toBeUndefined()
const stillWildcard = new DatabaseSync(wildcardPath)
expect(stillWildcard.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
expect(stillWildcard.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
expect(stillWildcard.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
expect(stillWildcard.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' })
stillWildcard.close()
const otherAppPath = await temporaryPath('other-app.db')
const otherApp = new DatabaseSync(otherAppPath)
otherApp.exec('PRAGMA application_id = 123')

View File

@@ -1908,10 +1908,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Folder · docs/')
})
result.terminal.send('\t')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('File · design notes.md')
})
result.terminal.output = ''
result.terminal.send('\t')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('@"docs/design notes.md"')
})
await tick()
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })