docs: domain KV storage + workspace Agent Note and dev plan
Proposed-lifecycle Agent Note (Chinese draft, English body to follow after review) recording the storage hub / domain form / workspace design, the deletion semantics deferred to future work, alternatives considered, and the session-backend migration reuse audit. The dev plan under missions/ carries the engineering breakdown.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求:
|
||||
|
||||
- **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。
|
||||
- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。
|
||||
|
||||
另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。
|
||||
|
||||
## Proposal
|
||||
|
||||
新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面)、两个后端、domain 领域数据形式——及 workspace 消费者包;给 `SessionPersistence` 扩删除原语。
|
||||
|
||||
| 包 | 路径 | ctx 面 | 本期 |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage`(枢纽) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册 backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册 backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-domain` | `packages/storage/domain/` | 挂载 `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` 扩面 + 级联删编排 | `packages/session-persistence/*` | 既有 seam 新方法 | ✗ future work(本期不动 session 侧) |
|
||||
| `workspace.*` / `session.delete` RPC、GUI 接线、boot 组装 | — | — | ✗ 下期 |
|
||||
|
||||
(workspace 放独立组不放 `packages/host/`:host 组命名规则要求 `dsh-host-*` 前缀,而包名定为 `dsh-workspace`;且 workspace 实体是领域概念,不绑定 host 装配层。与既有 `workspace-context` 包无关——那是 AGENTS.md 指令加载器。)
|
||||
|
||||
依赖方向:`dsh-workspace` → `dsh-domain` → `dsh-storage` ← 两后端。`dsh-workspace` 另依赖 `ctx.sessionPersistence` 的只读面(attach 的 cwd 校验读 session header;服务缺席时 attach 直接拒绝——无法校验即不写账)。session 删除相关的 `ctx.sessions` 运行中检查随级联删一并归入 future work。
|
||||
|
||||
### `dsh-storage`:存储枢纽
|
||||
|
||||
纯注册枢纽,自身不做 IO,无 Config。
|
||||
|
||||
```ts
|
||||
declare module 'cordis' { interface Context { storage: Storage } }
|
||||
|
||||
export class Storage extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'storage')
|
||||
readonly backend: BackendRegistry
|
||||
/** Domain data form; present once dsh-domain is loaded. Unmounted access → throw. */
|
||||
readonly domain: DomainFacility
|
||||
/** Mount a data-form facility. Returns the disposer. Duplicate mount → throw. */
|
||||
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void
|
||||
}
|
||||
|
||||
/** Merge-extensible map of data forms; dsh-domain merges `domain: DomainFacility`. */
|
||||
export interface StorageForms {}
|
||||
|
||||
export class BackendRegistry {
|
||||
/** Register a named backend. Returns the disposer (unregisters). Duplicate name → throw. */
|
||||
register(name: string, backend: StorageBackend): () => void
|
||||
/** Resolve by name. Unknown → throw StorageError('backend-not-found'). */
|
||||
get(name: string): StorageBackend
|
||||
names(): string[]
|
||||
}
|
||||
```
|
||||
|
||||
**多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置(见下),不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。
|
||||
|
||||
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud:
|
||||
|
||||
```ts
|
||||
export interface StorageBackend {
|
||||
readonly name: string
|
||||
readonly kv?: KvFacet // 迁移期扩展:readonly log?: LogFacet
|
||||
/** Drain in-flight writes and release the medium. Idempotent. */
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
export interface KvFacet {
|
||||
/** Open (create or load) one unit. Version mismatch / malformed medium → throw. */
|
||||
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
|
||||
}
|
||||
|
||||
export interface KvUnitDescriptor {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$,兼作文件名/SQL 表名段
|
||||
readonly version: number
|
||||
readonly tables: readonly string[] // 同字符集约束
|
||||
readonly hasGlobal: boolean
|
||||
}
|
||||
|
||||
/** One opened unit. Values are opaque JSON to this layer. */
|
||||
export interface KvUnit {
|
||||
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }>
|
||||
putRecord(table: string, key: string, value: unknown): Promise<void>
|
||||
deleteRecord(table: string, key: string): Promise<void> // missing key = no-op
|
||||
setGlobal(value: unknown): Promise<void>
|
||||
close(): Promise<void> // idempotent
|
||||
}
|
||||
```
|
||||
|
||||
backend 契约(共享契约测试逐条断言,两后端同套件):
|
||||
|
||||
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
|
||||
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
|
||||
3. 持久性:写原语 resolve 后进程崩溃再 open,`loadAll` 必须反映该写入。
|
||||
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;后端只保证单次调用原子(JSON 整文件替换 / SQLite 单语句)。
|
||||
5. `deleteRecord` 幂等;`putRecord` 覆写。
|
||||
6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。
|
||||
7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。
|
||||
|
||||
```ts
|
||||
export type StorageErrorCode =
|
||||
| 'backend-not-found' | 'form-not-mounted' | 'duplicate-backend' | 'duplicate-mount'
|
||||
| 'version-mismatch' | 'malformed-medium' | 'closed'
|
||||
export class StorageError extends Error { readonly code: StorageErrorCode }
|
||||
```
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
```ts
|
||||
export const Config = z.object({ root: z.string().required() }) // schemastery;无默认
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const backend = new JsonStorageBackend(config)
|
||||
ctx.effect(() => {
|
||||
const dispose = ctx.storage.backend.register('json', backend)
|
||||
return async () => { dispose(); await backend.close() }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- 布局 `<root>/<unitName>.json`,一 unit 一文件;目录 0o700、文件 0o600。
|
||||
- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- 写入:任何一次写原语 = 内存态全量序列化 → temp 写 + fsync → rename 原子发布(Windows 变体照抄 session-persistence-jsonl 的 win32 路径)。内存态是权威,盘是投影。
|
||||
- `loadAll`:open 时整文件 parse;缺 `unit` 头、tables 非对象等 → `malformed-medium`。文件不存在 = 空单元,首写才落盘。
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
```ts
|
||||
export const Config = z.object({
|
||||
path: z.string().required(), // ':memory:' 允许
|
||||
journalMode: z.union(['wal', 'delete', 'truncate', 'persist']).default('wal'),
|
||||
})
|
||||
// apply 同 json:new SqliteStorageBackend(config) → register('sqlite', …)
|
||||
```
|
||||
|
||||
- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-sqlite:mkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。
|
||||
- 物理布局版本 `STORAGE_SQLITE_SCHEMA_VERSION = 1` 存 `PRAGMA user_version`:0 → 盖章;≠ → `version-mismatch`。
|
||||
- DDL(全 STRICT;表名由受限字符集拼接加 `u_` 前缀,杜绝外部输入进 DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
|
||||
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
|
||||
|
||||
### `dsh-domain`:领域数据形式
|
||||
|
||||
单实现不抽象;消费者只依赖这层,不直接触后端。
|
||||
|
||||
```ts
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。)
|
||||
|
||||
域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config):
|
||||
|
||||
```ts
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` 精确语义(顺序执行,任一步失败即整体失败):
|
||||
|
||||
1. 同名域已打开 → `DomainError('already-open')`。
|
||||
2. 后端名 = `config.routes[spec.name] ?? config.backend`;`ctx.storage.backend.get(name)`(未挂载穿透 `backend-not-found`——misconfiguration fails loud)。
|
||||
3. 后端无 `kv` facet → `DomainError('facet-unsupported')`。
|
||||
4. `kv.open(descriptorOf(spec))`(descriptor 由 spec 直接投影)。
|
||||
5. `loadAll()`;每条记录 `valueSchema.parse`,global 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。
|
||||
6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。
|
||||
|
||||
```ts
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
|
||||
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
|
||||
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
|
||||
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
|
||||
- **变更事件**:每次写落盘 resolve 后 emit,逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);此为下期 RPC 推帧的事件源:
|
||||
|
||||
```ts
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* A domain record or global changed (post-durability).
|
||||
* @mode emit
|
||||
* @param change - domain, table ('' for global), key ('' for global),
|
||||
* operation, and the new snapshot (absent for deletions).
|
||||
*/
|
||||
'domain/changed'(change: DomainChanged): void
|
||||
}
|
||||
}
|
||||
export interface DomainChanged {
|
||||
readonly domain: string
|
||||
readonly table: string
|
||||
readonly key: string
|
||||
readonly operation: 'put' | 'deleted'
|
||||
readonly value?: unknown
|
||||
}
|
||||
|
||||
export type DomainErrorCode =
|
||||
| 'already-open' | 'facet-unsupported' | 'invalid-record' | 'missing-key' | 'closed'
|
||||
export class DomainError extends Error { readonly code: DomainErrorCode }
|
||||
```
|
||||
|
||||
### Future work:session 侧删除(设计定案,本期不实施)
|
||||
|
||||
本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。
|
||||
|
||||
```ts
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL 后端:unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。
|
||||
- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`;0 行命中且无 intent → reject。
|
||||
- 删除成功后 emit `'session-persistence/deleted'(id: SessionId)`(`@mode emit`;session-persistence 层事件面,与 `domain/changed` 无关)。派生数据(session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。
|
||||
|
||||
编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则):
|
||||
|
||||
| 检查(按序) | 不满足时 |
|
||||
| --- | --- |
|
||||
| 目标(递归时含整棵子树)无一在 `ctx.sessions` 运行 | throw,什么都不删;调用方先 cancel 再删,持久层不反向牵动运行时 |
|
||||
| 非递归时目标无后代(后代 = `parentSessionId` 传递闭包,由 `list()` header 求得) | throw:默认只能删叶子,`recursive: true` 显式递归 |
|
||||
| 递归序自底向上(叶→根) | ——中途崩溃只留"子树删一半、祖先在",重跑收敛,任何时刻无悬空 parent |
|
||||
| 级联中某 id 已不在盘上 | 跳过(幂等续删);其余错误中止 |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
包拥有 `WorkspaceId` brand,暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key:规范化会改写它,引用锚点必须稳定。
|
||||
|
||||
```ts
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
}
|
||||
```
|
||||
|
||||
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。
|
||||
- **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。
|
||||
- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。
|
||||
- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。
|
||||
|
||||
一致性口径(账 = 归属唯一依据;实现与测试基准):
|
||||
|
||||
| 情形 | 行为 |
|
||||
| --- | --- |
|
||||
| 账中 id 盘上无 session | `list()`/实体投影时过滤;下次任何 mutate 顺手摘除;不报错(删除崩溃一致性的正常产物) |
|
||||
| session cwd 匹配某 workspace 但未上账 | 不属于:不合并不收编。GUI 将来可做"游离 session"专区(游离 = 全部账的补集) |
|
||||
| 同一 session 上两本账 | 写侧结构性堵死(attach 校验);load 检出 → throw(外部手改数据,不掩盖) |
|
||||
| workspace 目录不存在 | 记录与账保留,`status()` = `'missing-dir'`;存储层不自动删(目录可能只是暂时挪走) |
|
||||
|
||||
### 复用与 session 后端迁移展望
|
||||
|
||||
**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是 append-only 流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。
|
||||
|
||||
现状复用审计(迁移前就能看清的账):
|
||||
|
||||
| session-persistence 现有逻辑 | 归属 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| JSONL:temp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体(win32.ts) | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 |
|
||||
| JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 |
|
||||
| SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 |
|
||||
| encodeSegment(id 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet(一 session 一文件)迁移时随之下沉 |
|
||||
|
||||
**本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。
|
||||
|
||||
### 测试矩阵
|
||||
|
||||
| 套件 | 覆盖 | 后端 |
|
||||
| --- | --- | --- |
|
||||
| backend 契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite(`:memory:` + 临时目录) |
|
||||
| registry/mount | 重复注册、未挂载访问、disposer 摘除 | — |
|
||||
| domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一(json) |
|
||||
| workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json |
|
||||
| session delete 契约(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite |
|
||||
|
||||
快照:本期无模型可见面与组装面,不新增;下期 RPC 接线时随 `workspace.*` 域补。
|
||||
|
||||
### 不做清单
|
||||
|
||||
| 不做 | 触发条件 | 返工点 | 预埋 |
|
||||
| --- | --- | --- | --- |
|
||||
| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 |
|
||||
| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 |
|
||||
| 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 |
|
||||
| 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` |
|
||||
| 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 |
|
||||
| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite,数据手工导一次 | 路由即配置,消费者零改动 |
|
||||
| 多段 key | 两段 key 消费者出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key |
|
||||
| scope 维度 | "每 workspace 一份"的域出现且复合 key 表达不动 | DomainSpec 加 scope + 文件名 scope 段(encodeSegment) | 名字字符集已收紧,文件名不冲突 |
|
||||
| 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — |
|
||||
| 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 |
|
||||
| session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — |
|
||||
| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **复用 session-persistence 的 coordinator/后端**:事件日志语义(append-only、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。
|
||||
- **workspace 专用存储包,后续再抽 seam**:第二个消费者(session sidecar)已可预见,届时泛化要再动一次接口。
|
||||
- **domain 与 storage 合为一层**:后端会被迫接触 schema 校验、变更事件、写串行等领域关切;拆开后 storage 后端只做不透明原语(可替换面最小),domain 单实现收敛全部领域逻辑(zod/事件/串行化只写一遍,不随后端翻倍)。
|
||||
- **整库单后端二选一(学 session-persistence 单坑位模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单坑位会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步,fail-loud 兜底。
|
||||
- **JSON 后端 jsonl 追加 + 墓碑 + 压实**:temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。
|
||||
- **JSON 一表一文件**:覆写下文件粒度不影响写成本,按域合并文件更少,global 单例有落点。
|
||||
- **SQLite 整域存单行 blob**:任何一条记录变更都重写整域,失去按 key 精确更新——SQLite 相对 JSON 的唯一优势归零。
|
||||
- **SQLite 按 schema 生成 typed columns**:DDL 生成器过度建设;document-per-row 足够,查询需求出现再议。
|
||||
- **每域独立 sqlite db 文件**:与仓库一库多表惯例相反。
|
||||
- **path 作为 workspace key**:规范化/符号链接解析会改写 path;引用锚点必须稳定。
|
||||
- **归属用 cwd 派生(或与账合并)**:双真相源;cwd 表达不了排序;归属本就是 workspace 侧事实。
|
||||
- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费者);需要 diff 的消费者自己持有上次快照。
|
||||
- **删除自动 cancel 运行中 session**:持久层/编排层反向牵动运行时,层次变脏;cancel 机制已存在,调用方组合即可。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。
|
||||
- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。
|
||||
- session-persistence 包零 diff(本期不动 session 侧的验收线)。
|
||||
- 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。
|
||||
|
||||
## Risks
|
||||
|
||||
- **仓库持久化面第一个推式变更事件**(session-persistence 靠 revision 轮询):形态虽有 `goal/changed` 范本,但"存储层发事件"是新先例,下期 RPC 消费时才能验证形态是否合适。
|
||||
- **JSON 后端整域覆写的规模前提**:若第二个消费者(session sidecar)在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。
|
||||
- **删除语义的编排层检查依赖 `ctx.sessions` 弱依赖**:headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session);多进程本就在不做清单内,接受。
|
||||
- **facet 泛化以未来的 `log` facet 为设计依据但本期不实现它**:存在"预留形状不合身"的风险;缓解是本期后端介质代码按复用审计表的下沉形状组织,`log` facet 真正落地时只动 facet 层。
|
||||
@@ -0,0 +1,179 @@
|
||||
# Storage + Workspace 工程开发文档
|
||||
|
||||
> 施工范围:5 个新包,session 侧零 diff。规范正典:[Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)——本文只写工程拆解(目录/文件、class 落位、teammate 分工、并行依赖),接口语义以 Note 为准,冲突时改这里不改 Note(除非经用户拍板)。
|
||||
> 门禁口径:GUI 免门禁期同款——不随手写测试门禁,跑 typecheck/build 保证编译;测试文件按仓库惯例落位(包级 `tests/`、`.spec.ts`),红绿在 PR 窗口收口。
|
||||
|
||||
## 0. 总览
|
||||
|
||||
```
|
||||
packages/storage/
|
||||
storage/ dsh-storage 枢纽:Storage service + BackendRegistry + StorageForms
|
||||
storage-json/ dsh-storage-json JsonStorageBackend(kv facet)
|
||||
storage-sqlite/ dsh-storage-sqlite SqliteStorageBackend(kv facet)
|
||||
domain/ dsh-domain DomainFacility + Domain + KvTable + domain/changed
|
||||
packages/workspace/
|
||||
workspace/ dsh-workspace WorkspaceRegistry + WorkspaceEntity + workspaceDomainSpec
|
||||
```
|
||||
|
||||
依赖与并行关系(→ = 依赖):
|
||||
|
||||
```
|
||||
W1 storage(枢纽) ──→ W2a storage-json ──┐
|
||||
└──→ W2b storage-sqlite ─┼──→ 集成冒烟(W4 兼)
|
||||
└──→ W3 domain ──────────┘
|
||||
└──→ W4 workspace
|
||||
```
|
||||
|
||||
- W1 先行(接口包是所有人的编译依赖),完成后 W2a/W2b/W3 **三线并行**;W4 依赖 W3 的接口定型(不必等 json/sqlite 完工,可对着 W3 的类型先写,用内存假 backend 跑测试)。
|
||||
- 每包的 package.json/tsconfig/README/invariant 伴生由该包 owner 自己配齐(模板照抄 `packages/session-persistence/session-persistence-sqlite/` 的形状)。
|
||||
|
||||
## 1. W1:`dsh-storage`(枢纽)——主线程自做
|
||||
|
||||
量小且是全组编译根,主线程直接写,不派 teammate。
|
||||
|
||||
```
|
||||
packages/storage/storage/
|
||||
package.json # 无运行时依赖;cordis peerDep + dev
|
||||
tsconfig.json
|
||||
src/index.ts # Storage service + apply + 全部导出
|
||||
src/registry.ts # BackendRegistry
|
||||
src/backend.ts # StorageBackend/KvFacet/KvUnitDescriptor/KvUnit 类型
|
||||
src/error.ts # StorageError + code 联合
|
||||
src/invariant.ts # 见下
|
||||
tests/registry.spec.ts # registry/mount 套件
|
||||
README.md
|
||||
```
|
||||
|
||||
class/接口逐条(签名以 Note 为准,此处列实现要点):
|
||||
|
||||
| 成员 | 实现要点 |
|
||||
| --- | --- |
|
||||
| `class Storage extends Service` | `super(ctx, 'storage')`;`readonly backend = new BackendRegistry()`;`mount(form, facility)` 存入私有 `Map<keyof StorageForms, unknown>`,重复 → `StorageError('duplicate-mount')`,返回删除闭包;`get domain()` 从 map 取,缺 → `StorageError('form-not-mounted')` |
|
||||
| `class BackendRegistry` | 私有 `Map<string, StorageBackend>`;`register` 重名 → `duplicate-backend`,返回 `() => map.delete(name)`;`get` 缺名 → `backend-not-found`;`names()` 返回数组拷贝 |
|
||||
| `interface StorageForms {}` | 空接口 + JSDoc(merge-extensible,键 = 数据形式名) |
|
||||
| `interface StorageBackend / KvFacet / KvUnitDescriptor / KvUnit` | 纯类型 + 契约 JSDoc(七条契约写在 KvUnit 各方法 JSDoc 上——这是 backend 实现者的规范文本) |
|
||||
| `class StorageError extends Error` | `constructor(code, message?, cause?)`;`name = 'StorageError'` |
|
||||
| `const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` | 导出;descriptor 校验用(backend open 时验,fail loud) |
|
||||
| invariant | 枢纽自身无运行时不变量(纯注册表,无事件流/可变盘面),写"explained empty"(措辞照抄 sqlite 后端 invariant.ts 的 "No runtime invariant:" 模板) |
|
||||
|
||||
事件面:本包**无**事件(`domain/changed` 归 dsh-domain)。
|
||||
|
||||
## 2. W2a:`dsh-storage-json` —— teammate **json-backend**
|
||||
|
||||
```
|
||||
packages/storage/storage-json/
|
||||
src/index.ts # Config + apply + JsonStorageBackend
|
||||
src/unit.ts # JsonKvUnit
|
||||
src/atomic.ts # temp+fsync+rename 原子写(含 win32 分支)
|
||||
src/format.ts # 文件格式 parse/serialize + malformed 检查
|
||||
src/invariant.ts
|
||||
tests/json-backend.spec.ts # 挂共享契约套件(见 §5)+ json 特有(文件肉眼格式、malformed)
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | schemastery,`root: z.string().required()`(JSDoc 说明为何无默认:防 cwd 散落,参照 session-persistence 措辞) |
|
||||
| `class JsonStorageBackend implements StorageBackend` | `name='json'`;`kv = { open }`;持 `Map<unitName, JsonKvUnit>`(同名重复 open → 复用还是报错:**报错**,unit 生命周期归调用方,double-open 是 bug);`close()` 逐 unit close,幂等 |
|
||||
| `class JsonKvUnit implements KvUnit` | 内存态 `{ version, global, tables: Map<string, Map<string, unknown>> }` 为权威;构造时读盘:文件缺失 = 空单元(不落盘),存在则 parse + 版本比对;每个写原语 = 改内存 → `writeAtomic(serialize())`;**写不排队**(契约第 4 条:串行是调用方的事),但单次 writeAtomic 内部完整(temp/fsync/rename);close 后操作 → `closed` |
|
||||
| `atomic.ts` | `writeAtomic(path, data)`:同目录 temp 文件 + fsync + rename;win32 分支照抄 `session-persistence-jsonl/src/win32.ts` 的替换语义(先照抄,`log` facet 迁移期再提共享——Note 已记)|
|
||||
| `format.ts` | `serialize(unit): string`(`JSON.stringify(…, null, 2)` + 尾换行);`parse(text): ParsedUnit`,缺 `unit` 头/结构不符 → `malformed-medium` |
|
||||
| apply | `ctx.effect(() => { const d = ctx.storage.backend.register('json', backend); return async () => { d(); await backend.close() } })`;inject: `['storage']` |
|
||||
| invariant | 断言候选:rename 发布后盘上文件必可 parse 回等价内存态(写后读回校验,仅测试态开启);若判断无运行时可断言关系则 explained empty |
|
||||
|
||||
## 3. W2b:`dsh-storage-sqlite` —— teammate **sqlite-backend**
|
||||
|
||||
```
|
||||
packages/storage/storage-sqlite/
|
||||
src/index.ts # Config + apply + SqliteStorageBackend
|
||||
src/unit.ts # SqliteKvUnit
|
||||
src/schema.ts # SCHEMA_VERSION + openDatabase + DDL
|
||||
src/invariant.ts
|
||||
tests/sqlite-backend.spec.ts
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | `path: z.string().required()`(`:memory:` 允许)+ `journalMode` 枚举 default 'wal' |
|
||||
| `schema.ts` | `STORAGE_SQLITE_SCHEMA_VERSION = 1`;`openDatabase(config)` 照抄 session-persistence-sqlite 的序列(mkdir 0o700 → wx 0o600 建文件 → PRAGMA foreign_keys → journal_mode → user_version 检查盖章/拒绝 → 建 `units`/`unit_globals`);**先照抄不提共享 helper**(Note 已记:提取放迁移期) |
|
||||
| `class SqliteStorageBackend` | `name='sqlite'`;单 `DatabaseSync` 连接;`kv.open(descriptor)`:校验名字字符集 → `units` 行版本比对(无行则 INSERT 盖章)→ 按 descriptor.tables 逐张 `CREATE TABLE IF NOT EXISTS "u_<unit>_<table>"` → 返回 unit;`close()` 关连接 |
|
||||
| `class SqliteKvUnit` | 预编译语句(每表 upsert/delete/select-all + global upsert);`loadAll` 全表 SELECT 组装;`putRecord` = `INSERT … ON CONFLICT(key) DO UPDATE`;单语句原子,无显式事务;value `JSON.stringify`/parse |
|
||||
| invariant | 断言候选:STRICT 表 + user_version 与常量一致(open 后检);或 explained empty |
|
||||
|
||||
## 4. W3:`dsh-domain` —— teammate **domain-layer**
|
||||
|
||||
```
|
||||
packages/storage/domain/
|
||||
src/index.ts # Config + apply + DomainFacility
|
||||
src/spec.ts # DomainSpec/defineDomain/domainTable + descriptorOf
|
||||
src/domain.ts # DomainImpl + KvTableImpl + 写链
|
||||
src/events.ts # domain/changed declaration merging
|
||||
src/error.ts # DomainError
|
||||
src/invariant.ts
|
||||
tests/domain.spec.ts # 用内存假 backend(tests/helpers/memory-backend.ts)
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | `backend: z.string().required()` + `routes: z.dict(z.string()).default({})` |
|
||||
| `spec.ts` | `defineDomain` 恒等函数(编译期收窄)+ 名字/表名正则校验(违规 throw,misconfiguration fails loud);`descriptorOf(spec)` 投影 |
|
||||
| `class DomainFacility` | 持 `Map<domainName, DomainImpl>`(already-open 检查);`open(spec)` 按 Note 六步实现;zod 依赖在此包(dependencies,不是 peer) |
|
||||
| `class DomainImpl` | 写链 `chain: Promise<void>`(`enqueue<T>(job): Promise<T>` 私有方法,所有写走它);内存态 `Map<table, Map<key, value>>` + global;每写:链上 → 改内存 → unit 原语 await → `ctx.emit('domain/changed', …)`;dispose:`enqueue(noop)` 排空 → `unit.close()` |
|
||||
| `class KvTableImpl<K,V>` | 读同步走内存;`update` fn 同步纯(类型上 `(current: V) => V`),缺 key → `missing-key`;`delete` 返回是否存在 |
|
||||
| `events.ts` | 按 Note 全文(`@mode emit` + `@param`);`DomainChanged` 接口导出 |
|
||||
| invariant | 断言候选(真不变量,建议做):**每次 `domain/changed` 事件的 value 必等于内存态当前值**(事件流 vs 可变数据的 owned relationship,正合仓库 invariant 规范)|
|
||||
| tests/helpers/memory-backend.ts | `MemoryStorageBackend`:Map 实现 KvUnit,宣称版本可注入——共享给 W4 用 |
|
||||
|
||||
## 5. 共享 backend 契约套件 —— domain-layer 兼写(或主线程)
|
||||
|
||||
```
|
||||
packages/storage/storage/tests/contract.ts # export function runKvBackendContract(factory)
|
||||
```
|
||||
|
||||
- 仿 `runPersistenceContract` 形状:`factory: () => Promise<{ backend, reopen(): Promise<StorageBackend> }>`,两后端 spec 文件各自 import 调用。
|
||||
- 覆盖 Note 七条契约 + 版本拒绝 + close 幂等;"崩溃再 open"用 `reopen()`(新实例指向同一介质)模拟。
|
||||
- 落在接口包 tests/ 下(不进 src,不发布),json/sqlite 的 devDependencies 指向 workspace 接口包即可复用。
|
||||
|
||||
## 6. W4:`dsh-workspace` —— teammate **workspace-domain**
|
||||
|
||||
```
|
||||
packages/workspace/workspace/
|
||||
src/index.ts # apply + WorkspaceRegistry(service 挂 ctx.workspace)
|
||||
src/types.ts # WorkspaceId brand + Workspace 接口
|
||||
src/spec.ts # workspaceRecord zod + workspaceDomainSpec
|
||||
src/entity.ts # WorkspaceEntity(不出包:index.ts 不 re-export)
|
||||
src/paths.ts # realpathNormalize(path)
|
||||
src/invariant.ts
|
||||
tests/workspace.spec.ts # MemoryStorageBackend + 假 sessionPersistence stub
|
||||
```
|
||||
|
||||
(删除入口本期不存在:registry 无 delete、entity 无关联清理——整套删除语义在 Agent Note 的 future work 节。)
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `types.ts` | `WorkspaceId` brand + 工厂;`Workspace` 接口(Note 签名照录,JSDoc 齐全——这是对外契约) |
|
||||
| `spec.ts` | `workspaceRecord`(path/title/sessionIds/createdAt/updatedAt)+ `workspaceDomainSpec = defineDomain({ name: 'workspace', version: 1, tables: { workspaces: … } })` |
|
||||
| `paths.ts` | `realpathNormalize(p): Promise<string>`——`fs.realpath`;ENOENT 原样抛(create 的 reject 路径) |
|
||||
| `class WorkspaceRegistry extends Service` | `super(ctx, 'workspace')`;inject `['storage', 'sessionPersistence']`(sessionPersistence optional:`ctx.get()` 取,缺席时 attach 拒绝);`start()`:`ctx.storage.domain.open(workspaceDomainSpec)` + 重建 `Map<WorkspaceId, WorkspaceEntity>`;`create`:realpath → resolveByPath 撞 → reject;否则 `WorkspaceId(randomUUID())` + `table.put` + 建实体入缓存;`list()` 快照数组(过滤无效 sessionId 的投影在实体 getter 做);**无 delete 方法**(future work,与 session 级联一体落地) |
|
||||
| `class WorkspaceEntity implements Workspace` | 构造持 registry/id/record;getter 投影;`mutate(fn)` 私有:`table.update(id, r => stampUpdatedAt(fn(r)))` 后原地换 record;`attachSession`:读 `sessionPersistence.list()` 找 header(或 inspect),cwd realpath ≠ path → reject;幂等(已在账 → no-op);`detachSession` 摘账(不动 session 文件);`status()`:`fs.access(path)` |
|
||||
| 一致性口径 | ①账指向的 session 查无:**投影过滤**(getter 层)+ 下次 mutate 摘除;③双重账 load 检出 → throw;④missing-dir 只反映在 status() |
|
||||
| invariant | 断言候选:缓存实体集合与 domain 表 key 集合一致(owned relationship:registry 缓存 vs 权威盘面)|
|
||||
|
||||
## 7. Teammate 编成与节奏
|
||||
|
||||
| teammate | 包 | 开工条件 | 预估节奏 |
|
||||
| --- | --- | --- | --- |
|
||||
| (主线程) | W1 storage 枢纽 + §5 契约套件骨架 | 立即 | 首批落盘,随后进入 review/dispatcher 角色 |
|
||||
| json-backend | W2a | W1 类型可编译即开工 | 分批落盘:atomic/format 先行,unit 次之,契约套件接入收尾 |
|
||||
| sqlite-backend | W2b | 同上 | schema.ts 先行(照抄源已指明),unit 次之 |
|
||||
| domain-layer | W3 + memory-backend helper | 同上 | spec/error 先行 → DomainImpl 写链 → 事件 → 契约套件(若主线程未完成则兼) |
|
||||
| workspace-domain | W4 | W3 的 src 类型定型(不等其测试) | types/spec/paths 先行 → registry/entity → 测试 |
|
||||
|
||||
协作规矩(照 conventions):分批落盘每批几分钟内、每批一句话回执;产出零落盘超 5 分钟报告;不混 commit 别人的在途文件;代码注释一律英文且只写非显然契约;干完不 kill 保持待命。commit 纪律:`--no-verify`,按包分刀(W1 一刀 → W2a/W2b/W3 各一刀 → W4 一刀 → 测试/文档尾刀),文档(本文件 + Agent Note 增量)住顶刀。
|
||||
|
||||
## 8. 主线程验收清单(每包合入前)
|
||||
|
||||
- [ ] `pnpm run typecheck` 过(本期唯一硬门禁)
|
||||
- [ ] 包结构齐:package.json(`@deepseek-ai/dsh-*`、ESM、cordis peerDep)、README、invariant 伴生(真断言或 explained empty)
|
||||
- [ ] 接口与 Agent Note 一致;发现实现逼着改接口 → 停下来报主线程裁决(不擅改 Note)
|
||||
- [ ] 测试文件落位正确(包级 tests/、`.spec.ts`),能跑多少跑多少,红的记台账不追修
|
||||
- [ ] session-persistence 包零 diff(`git status` 检查线)
|
||||
Reference in New Issue
Block a user