Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # docs/cordis-catalog/services.md
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/ },
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user