mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(session): persist optional time zones
This commit is contained in:
@@ -38,7 +38,7 @@ interface SessionLocation {
|
||||
|
||||
## `SessionHeader` — metadata beside the log
|
||||
|
||||
Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`.
|
||||
Per-session metadata travels **separately** from the event log: format version, cwd, optional caller-validated time zone, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`.
|
||||
|
||||
Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -59,6 +59,11 @@ interface SessionHeader {
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
readonly cwd?: string
|
||||
/**
|
||||
* Optional caller-validated time-zone identifier captured at creation.
|
||||
* Session core preserves the exact string without interpreting or canonicalizing it.
|
||||
*/
|
||||
readonly timeZone?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
@@ -82,7 +87,7 @@ interface SessionHeader {
|
||||
|
||||
## `CreateSessionOptions` — seeding and metadata
|
||||
|
||||
Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume.
|
||||
Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, optional caller-validated `timeZone`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -99,6 +104,8 @@ interface CreateSessionOptions {
|
||||
*/
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
/** Caller-validated time-zone identifier to preserve verbatim in the header. */
|
||||
readonly timeZone?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
|
||||
@@ -38,7 +38,7 @@ interface SessionLocation {
|
||||
|
||||
## `SessionHeader`:日志旁的元数据
|
||||
|
||||
每个会话的元数据与事件日志**分开**存储:格式版本、cwd、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。
|
||||
每个会话的元数据与事件日志**分开**存储:格式版本、cwd、可选且由调用方校验的时区、血统与 seed 边界是存储层关注点而非对话事件,因此不进入 `SessionEventMap`,也不会到达 `deriveMessages()`。header 通过 `session.header` 附加到 `Session` 上。
|
||||
|
||||
源码:[`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -59,6 +59,11 @@ interface SessionHeader {
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
readonly cwd?: string
|
||||
/**
|
||||
* Optional caller-validated time-zone identifier captured at creation.
|
||||
* Session core preserves the exact string without interpreting or canonicalizing it.
|
||||
*/
|
||||
readonly timeZone?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
@@ -82,7 +87,7 @@ interface SessionHeader {
|
||||
|
||||
## `CreateSessionOptions`:seed 与元数据
|
||||
|
||||
通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。
|
||||
通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、可选且由调用方校验的 `timeZone`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -99,6 +104,8 @@ interface CreateSessionOptions {
|
||||
*/
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
/** Caller-validated time-zone identifier to preserve verbatim in the header. */
|
||||
readonly timeZone?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
|
||||
@@ -359,8 +359,8 @@ declare class Session {
|
||||
/** The ordered surface over this session's event log. */
|
||||
get surface(): SessionSurface;
|
||||
/**
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, time zone,
|
||||
* lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is created without a store-owned header, a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
|
||||
@@ -361,8 +361,8 @@ declare class Session {
|
||||
/** The ordered surface over this session's event log. */
|
||||
get surface(): SessionSurface;
|
||||
/**
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, time zone,
|
||||
* lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is created without a store-owned header, a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
|
||||
@@ -12,8 +12,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, optional `timeZone`, `seedLength`, `origin`, and `delegationDepth`.
|
||||
- `ctx.sessions.flush(session)` dispatches an awaited parallel checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; observe-only listeners return void, while a persistence listener returns literal `true` only after completing durability work. A fully successful checkpoint with at least one such acknowledgement returns `true` and emits contained `session/flushed(session, throughSeq)` with the exclusive event boundary captured at entry; no durability acknowledgement returns `false`, and unpublished, detached, or stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary.
|
||||
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
@@ -42,7 +43,7 @@ Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.c
|
||||
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
@@ -83,7 +84,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`. The optional `timeZone` is an opaque caller-validated string: session core checks only its stored shape and preserves it verbatim through reconstruction and fork. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
### 公共 API
|
||||
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
|
||||
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、可选的 `timeZone`、`seedLength`、`origin` 和 `delegationDepth`。
|
||||
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行检查点。每个监听器都会启动,调用会等待全部结算后才报告失败;仅观察的监听器返回 void,持久化监听器只有在完成持久化工作后才返回字面量 `true`。全部成功且至少有一个此类确认时,调用返回 `true`,并发布受包含的 `session/flushed(session, throughSeq)`,其中 `throughSeq` 是入口处捕获的事件排他边界;没有持久化确认时返回 `false`,未发布、已脱离或陈旧对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。
|
||||
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
@@ -42,7 +43,7 @@
|
||||
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
|
||||
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
|
||||
- `session.seq`、`session.id`:当前序号和只读类型化身份。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`timeZone`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。
|
||||
|
||||
### 无损 JSON 工具
|
||||
|
||||
@@ -83,7 +84,7 @@
|
||||
|
||||
### 元数据类型(`types.ts`)
|
||||
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, timeZone?, parentSession?, seedLength?, delegationDepth? }`。可选的 `timeZone` 是由调用方校验的不透明字符串:会话核心仅检查其存储形状,并在重建和 fork 过程中原样保留。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。
|
||||
|
||||
### 扩展点
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
|
||||
}
|
||||
}
|
||||
if (record.timeZone !== undefined && typeof record.timeZone !== 'string') {
|
||||
throw new Error('session header timeZone must be a string')
|
||||
}
|
||||
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
|
||||
throw new Error('session header parentSession must be a string')
|
||||
}
|
||||
@@ -448,8 +451,8 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, time zone,
|
||||
* lineage, seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is created without a store-owned header, a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
@@ -825,8 +828,8 @@ export class SessionStore extends Service {
|
||||
* Create a session owned by the calling fiber: disposing that fiber stops
|
||||
* event notification and removes the session from the store. `options.seed`
|
||||
* populates the session with a copy of those events (replay/fork);
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
|
||||
* and parent lineage, and delegation depth) as the immutable
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`, opaque
|
||||
* time-zone string, seed and parent lineage, and delegation depth) as the immutable
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
@@ -894,6 +897,7 @@ export class SessionStore extends Service {
|
||||
id: sessionId,
|
||||
createdAt: meta?.createdAt ?? Date.now(),
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.timeZone === undefined ? {} : { timeZone: meta.timeZone },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.origin === undefined ? {} : { origin: meta.origin },
|
||||
@@ -1102,6 +1106,7 @@ export class SessionStore extends Service {
|
||||
seed,
|
||||
meta: {
|
||||
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
|
||||
...liveSource.header.timeZone !== undefined ? { timeZone: liveSource.header.timeZone } : {},
|
||||
parentSession: liveSource.id,
|
||||
seedLength: seed.length,
|
||||
},
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface SessionHeader {
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
readonly cwd?: string
|
||||
/**
|
||||
* Optional caller-validated time-zone identifier captured at creation.
|
||||
* Session core preserves the exact string without interpreting or canonicalizing it.
|
||||
*/
|
||||
readonly timeZone?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
@@ -85,6 +90,8 @@ export interface CreateSessionOptions {
|
||||
*/
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
/** Caller-validated time-zone identifier to preserve verbatim in the header. */
|
||||
readonly timeZone?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
|
||||
@@ -63,7 +63,9 @@ function inherited(session: Session): readonly SessionEvent[] {
|
||||
describe('SessionStore.fork', () => {
|
||||
it('forks an empty live session as an empty child with lineage metadata', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
|
||||
const source = ctx.sessions.create(SessionId('empty-parent'), {
|
||||
meta: { cwd: '/workspace', timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
|
||||
const child = sessions.fork(source, undefined, SessionId('empty-child'))
|
||||
|
||||
@@ -71,11 +73,21 @@ describe('SessionStore.fork', () => {
|
||||
expect(child.header).toMatchObject({
|
||||
id: SessionId('empty-child'),
|
||||
cwd: '/workspace',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
parentSession: SessionId('empty-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a headerless fork headerless', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('headerless-parent'), { meta: { cwd: '/workspace' } })
|
||||
|
||||
const child = sessions.fork(source, undefined, SessionId('headerless-child'))
|
||||
|
||||
expect(child.header.timeZone).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forks the latest completed boundary by default into detached frozen seed events', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
|
||||
@@ -997,6 +997,7 @@ describe('Session', () => {
|
||||
id: SessionId('header-owned'),
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
timeZone: 'Caller/Canonical',
|
||||
parentSession: SessionId('parent'),
|
||||
seedLength: 2,
|
||||
}
|
||||
@@ -1009,6 +1010,7 @@ describe('Session', () => {
|
||||
id: 'header-owned',
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
timeZone: 'Caller/Canonical',
|
||||
parentSession: 'parent',
|
||||
seedLength: 2,
|
||||
})
|
||||
@@ -1063,6 +1065,7 @@ describe('Session', () => {
|
||||
{ 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, timeZone: 1 }, error: /header timeZone must be a string/ },
|
||||
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
@@ -1273,16 +1276,17 @@ describe('SessionStore', () => {
|
||||
expect(session.header.parentSession).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attaches cwd and parentSession from meta to the header', async () => {
|
||||
it('attaches cwd, timeZone, and parentSession from meta to the header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('child'), {
|
||||
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
||||
meta: { cwd: '/work/project', timeZone: 'Asia/Shanghai', parentSession: SessionId('parent') },
|
||||
})
|
||||
expect(session.header).toMatchObject({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'child',
|
||||
cwd: '/work/project',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
parentSession: 'parent',
|
||||
})
|
||||
})
|
||||
@@ -1307,6 +1311,7 @@ describe('SessionStore', () => {
|
||||
const cases: Array<{ meta: unknown; error: RegExp }> = [
|
||||
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
|
||||
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ meta: { timeZone: 1 }, error: /header timeZone must be a string/ },
|
||||
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ 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/ },
|
||||
|
||||
@@ -796,7 +796,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
methods: [
|
||||
{
|
||||
signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session',
|
||||
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
|
||||
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, opaque\n * time-zone string, seed and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
|
||||
},
|
||||
{
|
||||
signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session',
|
||||
@@ -1921,7 +1921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'CreateSessionOptions',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}',
|
||||
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'CredentialInfo',
|
||||
@@ -2589,7 +2589,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionHeader',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly timeZone?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionId',
|
||||
|
||||
@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
session.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, timeZone?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`. An optional string `timeZone` is preserved verbatim; its absence stays absent, and a non-string stored value rejects the log. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
|
||||
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
|
||||
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
|
||||
|
||||
@@ -14,7 +14,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
|
||||
session.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
|
||||
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, timeZone?, createdAt, parentSession?, seedLength?, origin?, delegationDepth }`。可选字符串 `timeZone` 会原样保留;缺失时保持缺失,已存储值不是字符串时会拒绝日志。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
|
||||
- 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
|
||||
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript(文本记录)时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
|
||||
- 会话 id 是未验证的带品牌类型的字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface HeaderLine {
|
||||
id: SessionId
|
||||
createdAt: number
|
||||
cwd?: string
|
||||
timeZone?: string
|
||||
parentSession?: SessionId
|
||||
seedLength?: number
|
||||
origin?: 'subagent'
|
||||
@@ -53,6 +54,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
|
||||
id: header.id,
|
||||
createdAt: header.createdAt,
|
||||
...header.cwd !== undefined ? { cwd: header.cwd } : {},
|
||||
...header.timeZone !== undefined ? { timeZone: header.timeZone } : {},
|
||||
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
|
||||
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
|
||||
...header.origin !== undefined ? { origin: header.origin } : {},
|
||||
@@ -74,6 +76,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
|
||||
id: line.id,
|
||||
createdAt: line.createdAt,
|
||||
...line.cwd !== undefined ? { cwd: line.cwd } : {},
|
||||
...line.timeZone !== undefined ? { timeZone: line.timeZone } : {},
|
||||
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
|
||||
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
|
||||
...line.origin !== undefined ? { origin: line.origin } : {},
|
||||
@@ -92,6 +95,8 @@ function isHeaderLine(value: unknown): value is HeaderLine {
|
||||
&& Number.isSafeInteger((value as { createdAt: number }).createdAt)
|
||||
&& (value as { createdAt: number }).createdAt >= 0
|
||||
&& !Object.is((value as { createdAt: number }).createdAt, -0)
|
||||
&& ((value as { timeZone?: unknown }).timeZone === undefined
|
||||
|| typeof (value as { timeZone?: unknown }).timeZone === 'string')
|
||||
&& typeof (value as { delegationDepth?: unknown }).delegationDepth === 'number'
|
||||
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
|
||||
&& (value as { delegationDepth: number }).delegationDepth >= 0
|
||||
|
||||
@@ -786,6 +786,15 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
|
||||
})
|
||||
|
||||
it('round-trips an optional timeZone and rejects a non-string stored value', () => {
|
||||
const zoned = meta('zoned-header', '/work', 'Asia/Shanghai')
|
||||
const scanned = scanLog(Buffer.from(`${JSON.stringify(toHeaderLine(zoned))}\n`))
|
||||
|
||||
expect(scanned.meta).toEqual({ ...zoned, delegationDepth: 0 })
|
||||
const invalid = { ...toHeaderLine(zoned), timeZone: 8 }
|
||||
expect(() => scanLog(Buffer.from(`${JSON.stringify(invalid)}\n`))).toThrow(/session header/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['a string', '1'],
|
||||
|
||||
@@ -10,9 +10,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` p
|
||||
|
||||
## 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; `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).
|
||||
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, and nullable `time_zone` preserves an optional `timeZone` string. 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 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.
|
||||
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. The one supported upgrade accepts an owned v13 database, adds nullable `time_zone`, and advances `user_version` to 14 inside the existing `BEGIN IMMEDIATE`; old rows remain `NULL`. A failure rolls back both changes. Non-pristine unversioned databases, foreign application identities, and every other version reject before journal-mode mutation.
|
||||
|
||||
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.
|
||||
|
||||
@@ -59,5 +59,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 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).
|
||||
- **Only a pristine new database, an owned v13 database eligible for the v14 upgrade, or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected.
|
||||
- **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).
|
||||
|
||||
@@ -10,9 +10,9 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见
|
||||
|
||||
## 存储模型
|
||||
|
||||
每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
|
||||
每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数,可为空的 `time_zone` 则保留可选的 `timeZone` 字符串。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
|
||||
|
||||
仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。
|
||||
仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。唯一受支持的升级接受自有 v13 数据库,在既有 `BEGIN IMMEDIATE` 中添加可为空的 `time_zone`,并将 `user_version` 推进到 14;旧行保持 `NULL`。失败会回滚这两项变更。非 pristine 无版本数据库、外部 application identity 和所有其他版本在 journal-mode 变更前均会被拒绝。
|
||||
|
||||
在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。
|
||||
|
||||
@@ -59,5 +59,5 @@ SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope
|
||||
|
||||
- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。
|
||||
- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。
|
||||
- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。
|
||||
- **只有 pristine 新数据库、符合 v14 升级条件的自有 v13 数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本都会被拒绝。
|
||||
- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。
|
||||
|
||||
@@ -380,12 +380,13 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
private writeRow(meta: SessionHeader): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
(id, version, created_at, cwd, time_zone, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
created_at = excluded.created_at,
|
||||
cwd = excluded.cwd,
|
||||
time_zone = excluded.time_zone,
|
||||
parent_session = excluded.parent_session,
|
||||
seed_length = excluded.seed_length,
|
||||
origin = excluded.origin,
|
||||
@@ -395,6 +396,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
meta.version,
|
||||
meta.createdAt,
|
||||
meta.cwd ?? null,
|
||||
meta.timeZone ?? null,
|
||||
meta.parentSession ?? null,
|
||||
meta.seedLength ?? null,
|
||||
meta.origin ?? null,
|
||||
|
||||
@@ -17,7 +17,55 @@ 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 = 13
|
||||
export const SCHEMA_VERSION = 14
|
||||
|
||||
/** The one owned schema layout this build upgrades in place. */
|
||||
const MIGRATABLE_SCHEMA_VERSION = 13
|
||||
|
||||
/** Exact user objects emitted by the v13 schema owner, before `time_zone`. */
|
||||
const MIGRATABLE_V13_SCHEMA = [
|
||||
{
|
||||
type: 'table',
|
||||
name: 'events',
|
||||
tableName: 'events',
|
||||
sql: `CREATE TABLE 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`,
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
name: 'persistence_state',
|
||||
tableName: 'persistence_state',
|
||||
sql: `CREATE TABLE persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
store_id TEXT NOT NULL
|
||||
) STRICT`,
|
||||
},
|
||||
{
|
||||
type: 'table',
|
||||
name: 'sessions',
|
||||
tableName: 'sessions',
|
||||
sql: `CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
delegation_depth INTEGER,
|
||||
incarnation TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
) STRICT`,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** SQLite application id protecting unrelated databases from persistence writes. */
|
||||
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
|
||||
@@ -34,6 +82,7 @@ export interface SessionRow {
|
||||
version: number
|
||||
created_at: number
|
||||
cwd: string | null
|
||||
time_zone: string | null
|
||||
parent_session: string | null
|
||||
seed_length: number | null
|
||||
origin: 'subagent' | null
|
||||
@@ -68,9 +117,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* zero `user_version` is initialized at {@link SCHEMA_VERSION}; an owned v13
|
||||
* database is upgraded atomically, while a nonempty unversioned database and
|
||||
* every other non-current version reject.
|
||||
* @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.
|
||||
@@ -102,14 +151,19 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
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) {
|
||||
if (onDisk !== 0 && onDisk !== MIGRATABLE_SCHEMA_VERSION && 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) {
|
||||
if ((onDisk === MIGRATABLE_SCHEMA_VERSION || 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}`,
|
||||
)
|
||||
}
|
||||
if (onDisk === MIGRATABLE_SCHEMA_VERSION) {
|
||||
assertMigratableV13Schema(db, path)
|
||||
db.exec('ALTER TABLE sessions ADD COLUMN time_zone TEXT')
|
||||
}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
@@ -121,6 +175,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
time_zone TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
@@ -145,6 +200,8 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
).run(randomUUID())
|
||||
if (onDisk === 0) {
|
||||
db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
|
||||
}
|
||||
if (onDisk === 0 || onDisk === MIGRATABLE_SCHEMA_VERSION) {
|
||||
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
@@ -166,6 +223,34 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
}
|
||||
|
||||
/** Reject spoofed or modified v13 layouts before the migration changes them. */
|
||||
function assertMigratableV13Schema(db: DatabaseSync, path: string): void {
|
||||
const objects = db.prepare(`
|
||||
SELECT type, name, tbl_name AS tableName, sql
|
||||
FROM sqlite_schema
|
||||
WHERE name NOT GLOB 'sqlite_*'
|
||||
ORDER BY type, name
|
||||
`).all() as Array<{ type: string; name: string; tableName: string; sql: string | null }>
|
||||
const matches = objects.length === MIGRATABLE_V13_SCHEMA.length
|
||||
&& objects.every((object, index) => {
|
||||
const expected = MIGRATABLE_V13_SCHEMA[index]
|
||||
return expected !== undefined
|
||||
&& object.type === expected.type
|
||||
&& object.name === expected.name
|
||||
&& object.tableName === expected.tableName
|
||||
&& object.sql !== null
|
||||
&& normalizeSchemaSql(object.sql) === normalizeSchemaSql(expected.sql)
|
||||
})
|
||||
if (!matches) {
|
||||
throw new Error(`session database at "${path}" does not match the owned v13 schema`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ignore formatting while preserving every schema token and its order. */
|
||||
function normalizeSchemaSql(sql: string): string {
|
||||
return sql.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the {@link SessionHeader} from a `sessions` row.
|
||||
* @param row - the `sessions` table row.
|
||||
@@ -180,6 +265,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
|
||||
id: row.id as SessionId,
|
||||
createdAt: row.created_at,
|
||||
...row.cwd !== null ? { cwd: row.cwd } : {},
|
||||
...row.time_zone !== null ? { timeZone: row.time_zone } : {},
|
||||
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
|
||||
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
|
||||
...row.origin !== null ? { origin: row.origin } : {},
|
||||
|
||||
@@ -40,6 +40,48 @@ async function freshDbPath(): Promise<string> {
|
||||
return join(dir, 'sessions.db')
|
||||
}
|
||||
|
||||
/** Create the exact owned v13 layout without passing through the v14 opener. */
|
||||
function createV13Database(path: string): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE persistence_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
store_id TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
origin TEXT,
|
||||
delegation_depth INTEGER,
|
||||
incarnation TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE 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;
|
||||
|
||||
PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID};
|
||||
PRAGMA user_version = 13;
|
||||
`)
|
||||
db.prepare('INSERT INTO persistence_state (singleton, store_id) VALUES (1, ?)').run('v13-fixture-store')
|
||||
return db
|
||||
}
|
||||
|
||||
/** 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()
|
||||
@@ -166,13 +208,14 @@ describe('rowToMeta', () => {
|
||||
version: 0,
|
||||
created_at: 1,
|
||||
cwd: null,
|
||||
time_zone: 'Asia/Shanghai',
|
||||
parent_session: null,
|
||||
seed_length: null,
|
||||
origin: 'subagent',
|
||||
incarnation: 'with-origin',
|
||||
revision: 1,
|
||||
delegation_depth: null,
|
||||
})).toMatchObject({ id: 'with-origin', origin: 'subagent' })
|
||||
})).toMatchObject({ id: 'with-origin', origin: 'subagent', timeZone: 'Asia/Shanghai' })
|
||||
})
|
||||
|
||||
it('rejects fractional stored creation metadata', () => {
|
||||
@@ -181,6 +224,7 @@ describe('rowToMeta', () => {
|
||||
version: 0,
|
||||
created_at: 1.5,
|
||||
cwd: null,
|
||||
time_zone: null,
|
||||
parent_session: null,
|
||||
seed_length: null,
|
||||
origin: null,
|
||||
@@ -328,7 +372,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await b2.dispose()
|
||||
})
|
||||
|
||||
it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
|
||||
it('rejects opening a database whose schema version is neither v13 nor the current build', async () => {
|
||||
const path = await freshDbPath()
|
||||
openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
|
||||
// Bump user_version past what this build supports.
|
||||
@@ -337,16 +381,84 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
dbNewer.close()
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
|
||||
|
||||
// The immediately preceding layout lacks the required store identity and is
|
||||
// rejected rather than migrated (unreleased software, no backward-compat).
|
||||
// Versions older than the one explicit migration remain unsupported.
|
||||
const olderPath = await freshDbPath()
|
||||
openDatabase(olderPath, 'wal').close()
|
||||
const dbOlder = openDatabase(olderPath, 'wal')
|
||||
dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
|
||||
dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 2}`)
|
||||
dbOlder.close()
|
||||
expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
|
||||
})
|
||||
|
||||
it('atomically migrates an owned v13 fixture and leaves old rows headerless', async () => {
|
||||
const path = await freshDbPath()
|
||||
const old = meta('v13-headerless', '/work')
|
||||
const legacy = createV13Database(path)
|
||||
legacy.prepare(`
|
||||
INSERT INTO sessions
|
||||
(id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, incarnation, revision)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, 1)
|
||||
`).run(old.id, old.version, old.createdAt, old.cwd ?? null, 'v13-headerless-incarnation')
|
||||
const insertEvent = legacy.prepare(
|
||||
'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
for (const event of oneTurnLog()) {
|
||||
const surface = event as SessionEvent<SurfaceEventType>
|
||||
insertEvent.run(
|
||||
old.id,
|
||||
event.seq,
|
||||
event.type,
|
||||
event.time,
|
||||
JSON.stringify(event.data),
|
||||
surface.sourceEventSeqs !== undefined ? JSON.stringify(surface.sourceEventSeqs) : null,
|
||||
surface.surfaceOp !== undefined ? JSON.stringify(surface.surfaceOp) : null,
|
||||
)
|
||||
}
|
||||
legacy.close()
|
||||
|
||||
const migrated = openDatabase(path, 'wal')
|
||||
expect(migrated.prepare('PRAGMA user_version').get()).toEqual({ user_version: 14 })
|
||||
expect(migrated.prepare('SELECT time_zone FROM sessions WHERE id = ?').get(old.id))
|
||||
.toEqual({ time_zone: null })
|
||||
migrated.close()
|
||||
|
||||
const mounted = await backend(path)
|
||||
try {
|
||||
const loaded = await mounted.ctx.sessionPersistence.load(old.id)
|
||||
expect(loaded.meta.timeZone).toBeUndefined()
|
||||
expect(loaded.events).toEqual(oneTurnLog())
|
||||
|
||||
const zoned = meta('v14-zoned', '/work', 'Asia/Shanghai')
|
||||
await mounted.ctx.sessionPersistence.create(zoned)
|
||||
await mounted.ctx.sessionPersistence.append(zoned.id, oneTurnLog())
|
||||
expect((await mounted.ctx.sessionPersistence.load(zoned.id)).meta.timeZone).toBe('Asia/Shanghai')
|
||||
} finally {
|
||||
await mounted.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a spoofed v13 layout without changing its schema or version', async () => {
|
||||
const path = await freshDbPath()
|
||||
const malformed = new DatabaseSync(path)
|
||||
malformed.exec(`
|
||||
CREATE TABLE sessions (id TEXT);
|
||||
PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID};
|
||||
PRAGMA user_version = 13;
|
||||
`)
|
||||
malformed.close()
|
||||
|
||||
expect(() => openDatabase(path, 'wal')).toThrow(/does not match the owned v13 schema/)
|
||||
|
||||
const unchanged = new DatabaseSync(path)
|
||||
const columns = unchanged.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
expect(columns.map(column => column.name)).toEqual(['id'])
|
||||
expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 13 })
|
||||
expect(unchanged.prepare(
|
||||
"SELECT name FROM sqlite_schema WHERE name IN ('persistence_state', 'events')",
|
||||
).all()).toEqual([])
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
|
||||
const path = await freshDbPath()
|
||||
const legacy = new DatabaseSync(path)
|
||||
@@ -408,23 +520,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
unchangedApplication.close()
|
||||
})
|
||||
|
||||
it('rejects a current-version database with a foreign application identity', async () => {
|
||||
it.each([13, SCHEMA_VERSION])('rejects a schema-v%i database with a foreign application identity', async (version) => {
|
||||
const path = await freshDbPath()
|
||||
const foreign = new DatabaseSync(path)
|
||||
foreign.exec('PRAGMA application_id = 12345')
|
||||
foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
|
||||
foreign.exec(`PRAGMA user_version = ${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 user_version').get()).toEqual({ user_version: 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 () => {
|
||||
it('rolls back tables created before persistence-state initialization fails', async () => {
|
||||
const path = await freshDbPath()
|
||||
const conflicting = new DatabaseSync(path)
|
||||
conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
|
||||
@@ -459,6 +571,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
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 })
|
||||
expect(db.prepare('PRAGMA table_info(sessions)').all()).toContainEqual(expect.objectContaining({
|
||||
name: 'time_zone',
|
||||
type: 'TEXT',
|
||||
notnull: 0,
|
||||
}))
|
||||
db.close()
|
||||
})
|
||||
|
||||
@@ -638,7 +755,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(13)
|
||||
expect(SCHEMA_VERSION).toBe(14)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service.
|
||||
|
||||
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
|
||||
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, optional time zone, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
|
||||
|
||||
## Service API (`ctx.sessionPersistence`)
|
||||
|
||||
@@ -33,7 +33,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
|
||||
Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure.
|
||||
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
|
||||
A live controller retains no seed copy. If first initialization rejects, the next flush borrows the current append-only Session log, rechecks the backend's actual cursor, and appends only the missing suffix before draining retained events. Concurrent retries share one initialization attempt; a committed-but-rejected write therefore neither duplicates the prefix nor permanently poisons the Session.
|
||||
|
||||
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, compares cwd and any stored `timeZone`, and never closes the active turn. A stored header without `timeZone` is the compatibility exception: a zoned live object may adopt it, but the stored header remains headerless and is never backfilled.
|
||||
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
|
||||
|
||||
@@ -54,11 +56,11 @@ The `PersistenceBackend<TornMarker>` hooks (the only contract between the coordi
|
||||
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
|
||||
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
|
||||
|
||||
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
The coordinator asserts the stored id and validates the optional stored `timeZone` as a string before repair or publication. Live adoption compares stored/live cwd and requires an exact live match when the stored header has a zone; an absent stored zone remains absent. Its `inspect()` path takes ownership of fresh backend values, validates and freezes them once, and retains at most the configured number of unpublished Sessions without calling `commitRepair`. A retained source is reused or repaired only when its revision still equals `readStoredRevision`; otherwise the coordinator reloads it. This freshness check does not add cross-process writer exclusion. Revision retries converge when the durable log remains unchanged for one read/check round trip; continuous external writers can delay `load`, `inspect`, or `prepare`. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
|
||||
## Metadata and location types
|
||||
|
||||
Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`, `origin?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn.
|
||||
Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `timeZone?`, `parentSession?`, `seedLength?`, `origin?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -589,6 +589,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) {
|
||||
return Promise.reject(new TypeError('session metadata createdAt must be a non-negative safe integer'))
|
||||
}
|
||||
if (snapshot.timeZone !== undefined && typeof snapshot.timeZone !== 'string') {
|
||||
return Promise.reject(new TypeError('session metadata timeZone must be a string'))
|
||||
}
|
||||
return this.serialize(snapshot.id, () => this.createCore(snapshot))
|
||||
}
|
||||
|
||||
@@ -801,7 +804,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
signal?.throwIfAborted()
|
||||
if (suffix === undefined) throw new Error(`session "${id}" not found`)
|
||||
this.assertStoredId(id, suffix.meta)
|
||||
this.assertVersion(suffix.meta)
|
||||
this.assertStoredHeader(suffix.meta)
|
||||
if (suffix.events.some(needsLegacyPrefix)) {
|
||||
const whole = await this.readStoredPrefix(id, signal)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
@@ -823,7 +826,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
signal?.throwIfAborted()
|
||||
if (stored === undefined) throw new Error(`session "${id}" not found`)
|
||||
this.assertStoredId(id, stored.meta)
|
||||
this.assertVersion(stored.meta)
|
||||
this.assertStoredHeader(stored.meta)
|
||||
return {
|
||||
meta: structuredClone(stored.meta),
|
||||
events: snapshotStoredEvents(stored.events, id),
|
||||
@@ -837,7 +840,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
try {
|
||||
const { meta, events, revision, tornMarker } = stored
|
||||
this.assertStoredId(id, meta)
|
||||
this.assertVersion(meta)
|
||||
this.assertStoredHeader(meta)
|
||||
const storedEvents = adoptStoredEvents(events, id)
|
||||
|
||||
// Preserve complete interrupted events and synthesize only missing closers.
|
||||
@@ -981,10 +984,14 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
private assertVersion(meta: SessionHeader): void {
|
||||
/** Validate fixed fields decoded from backend-owned storage. */
|
||||
private assertStoredHeader(meta: SessionHeader): void {
|
||||
if (meta.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`)
|
||||
}
|
||||
if (meta.timeZone !== undefined && typeof meta.timeZone !== 'string') {
|
||||
throw new Error(`stored session "${meta.id}" timeZone must be a string`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject backend metadata that is not bound to the requested session id. */
|
||||
@@ -994,6 +1001,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare the immutable metadata fields that participate in live adoption identity. */
|
||||
private assertAdoptableIdentity(meta: SessionHeader, session: Session): void {
|
||||
this.assertStoredHeader(meta)
|
||||
if (meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
// A stored headerless session is the one compatibility case: it remains
|
||||
// headerless even if a current caller supplied a zone for the live object.
|
||||
if (meta.timeZone !== undefined && meta.timeZone !== session.header.timeZone) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted with a different timeZone (persisted: ${meta.timeZone}, live: ${String(session.header.timeZone)}) (id collision)`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- write path (session/event → flush drain) ---
|
||||
|
||||
private installWritePath(): void {
|
||||
@@ -1161,9 +1181,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
// the stored header's cwd. The seed guard then ensures the live events
|
||||
// reproduce the persisted prefix; otherwise a fresh session reusing the
|
||||
// id could have its leading events filtered as already written.
|
||||
if (tracked.meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
this.assertAdoptableIdentity(tracked.meta, session)
|
||||
if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) {
|
||||
throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`)
|
||||
}
|
||||
@@ -1214,10 +1232,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix<TornMarker>): Promise<void> {
|
||||
const { meta, events, tornMarker } = stored
|
||||
this.assertStoredId(session.header.id, meta)
|
||||
if (meta.cwd !== session.header.cwd) {
|
||||
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
|
||||
}
|
||||
this.assertVersion(meta)
|
||||
this.assertAdoptableIdentity(meta, session)
|
||||
const storedEvents = snapshotStoredEvents(events, session.header.id)
|
||||
if (!seedCoversPrefix(seed, storedEvents)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
|
||||
@@ -21,12 +21,13 @@ export interface ContractBackend {
|
||||
}
|
||||
|
||||
/** Build a minimal {@link SessionHeader} for a session id. */
|
||||
export function meta(id: string, cwd?: string): SessionHeader {
|
||||
export function meta(id: string, cwd?: string, timeZone?: string): SessionHeader {
|
||||
return {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId(id),
|
||||
createdAt: 1000,
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...timeZone !== undefined ? { timeZone } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +87,49 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('s1', '/work')
|
||||
const m = meta('s1', '/work', 'Asia/Shanghai')
|
||||
const log = oneTurnLog()
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, log)
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
|
||||
expect(loaded.meta).toMatchObject(m)
|
||||
expect(loaded.events).toEqual(log)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a headerless session headerless across storage reads', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('headerless', '/work')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, oneTurnLog())
|
||||
|
||||
expect((await persistence.inspect(m.id)).meta.timeZone).toBeUndefined()
|
||||
expect((await persistence.load(m.id)).meta.timeZone).toBeUndefined()
|
||||
expect((await persistence.list()).find(header => header.id === m.id)?.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-string timeZone metadata without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const invalid = { ...meta('invalid-time-zone'), timeZone: 1 as unknown as string }
|
||||
await expect(persistence.create(invalid)).rejects.toThrow('session metadata timeZone must be a string')
|
||||
|
||||
const valid = meta('invalid-time-zone', undefined, 'UTC')
|
||||
await persistence.create(valid)
|
||||
await persistence.append(valid.id, oneTurnLog())
|
||||
expect((await persistence.load(valid.id)).meta.timeZone).toBe('UTC')
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a fractional creation timestamp without reserving its session id', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
|
||||
@@ -908,6 +908,69 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('stored-prefix adoption rejects a different present timeZone', async () => {
|
||||
const fix = await makeFixture()
|
||||
const first = await freshCtx(fix)
|
||||
try {
|
||||
const stored = first.ctx.sessions.create(SessionId('zone-adoption'), {
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
send(stored, oneTurnLog())
|
||||
await first.ctx.sessions.flush(stored)
|
||||
} finally {
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const live = ctx.sessions.create(SessionId('zone-adoption'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'America/New_York' },
|
||||
})
|
||||
const second = await fix.mount(ctx)
|
||||
try {
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('stored-prefix adoption keeps a headerless record headerless for a zoned live session', async () => {
|
||||
const fix = await makeFixture()
|
||||
const log = [
|
||||
...oneTurnLog(),
|
||||
{ type: 'session/end-seed', seq: 6, time: 7, data: {} },
|
||||
] as SessionEvent[]
|
||||
const first = await freshCtx(fix)
|
||||
try {
|
||||
const stored = first.ctx.sessions.create(SessionId('headerless-zone-adoption'), {
|
||||
seed: log,
|
||||
meta: { cwd: WORK },
|
||||
})
|
||||
await first.ctx.sessions.flush(stored)
|
||||
} finally {
|
||||
await first.fiber.dispose()
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const live = ctx.sessions.create(SessionId('headerless-zone-adoption'), {
|
||||
seed: log,
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
const second = await fix.mount(ctx)
|
||||
try {
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('HMR: adoption persists the live SUFFIX that was ahead of the stored prefix', async () => {
|
||||
const fix = await makeFixture()
|
||||
const ctx = new Context()
|
||||
@@ -1104,6 +1167,55 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('a zoned live session claims headerless ownerless state without backfilling it', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta('headerless-zone-claim', WORK))
|
||||
const live = ctx.sessions.create(SessionId('headerless-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(live.id)).meta.timeZone).toBeUndefined()
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('ownerless state with a timeZone only accepts the same live identity', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta('same-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const matching = ctx.sessions.create(SessionId('same-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'Asia/Shanghai' },
|
||||
})
|
||||
await expect(ctx.sessions.flush(matching)).resolves.toBe(true)
|
||||
expect((await ctx.sessionPersistence.load(matching.id)).meta.timeZone).toBe('Asia/Shanghai')
|
||||
|
||||
await ctx.sessionPersistence.create(meta('different-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const conflicting = ctx.sessions.create(SessionId('different-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK, timeZone: 'America/New_York' },
|
||||
})
|
||||
await expect(ctx.sessions.flush(conflicting)).rejects.toThrow(/different timeZone|id collision/)
|
||||
|
||||
await ctx.sessionPersistence.create(meta('missing-zone-claim', WORK, 'Asia/Shanghai'))
|
||||
const missing = ctx.sessions.create(SessionId('missing-zone-claim'), {
|
||||
seed: oneTurnLog(),
|
||||
meta: { cwd: WORK },
|
||||
})
|
||||
await expect(ctx.sessions.flush(missing)).rejects.toThrow(/different timeZone|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
|
||||
@@ -374,6 +374,28 @@ describe('PersistenceCoordinator bounded writes', () => {
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator stored identity', () => {
|
||||
it('rejects a non-string timeZone decoded by a backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
const id = SessionId('invalid-stored-zone')
|
||||
backend.store.set(id, {
|
||||
meta: { ...meta(id), timeZone: 1 as unknown as string },
|
||||
events: [],
|
||||
})
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
try {
|
||||
await expect(coordinator.inspect(id)).rejects.toThrow(/stored session .* timeZone must be a string/)
|
||||
expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a mismatched backend header before repair or state publication', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Reference in New Issue
Block a user