Merge remote-tracking branch 'origin/master' into worktree/acp-automation-protocol
# Conflicts: # docs/capability-seams.md # docs/event-producer-consumer.md # tsconfig.base.json
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f
|
||||
2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The host's only persistence surface is the session event log (`packages/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today:
|
||||
|
||||
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
|
||||
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
|
||||
|
||||
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
|
||||
|
||||
## Proposal
|
||||
|
||||
Create the `packages/storage/` group — the `ctx.storage` hub (backend registry + data-form mounts), two backends, the domain data form — plus the workspace consumer package; extend `SessionPersistence` with a delete primitive.
|
||||
|
||||
| Package | Path | ctx surface | This phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage` (the hub) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | registers backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | registers backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | mounts `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` extension + cascade orchestration | `packages/session-persistence/*` | new method on the existing seam | ✗ future work (session side untouched this phase) |
|
||||
| `workspace.*` / `session.delete` RPC, GUI wiring, boot assembly | — | — | ✗ next phase |
|
||||
|
||||
(workspace lives in its own group rather than `packages/host/`: the host group's naming rule requires the `dsh-host-*` prefix while this package is named `dsh-workspace`; and the workspace entity is a domain concept, not bound to the host assembly tier. Unrelated to the existing `workspace-context` package — that is an AGENTS.md instruction loader.)
|
||||
|
||||
Dependency direction: `dsh-workspace` → `dsh-domain` → `dsh-storage` ← the two backends. `dsh-workspace` additionally depends on the read-only face of `ctx.sessionPersistence` (attach's cwd check reads the session header; when the service is absent, attach rejects outright — no verification, no bookkeeping). The `ctx.sessions` running-check for session deletion moves into future work together with the cascade.
|
||||
|
||||
### `dsh-storage`: the storage hub
|
||||
|
||||
A pure registration hub, no IO of its own, no Config. The `Storage` service mounts at `ctx.storage` with two faces: `backend` (a `BackendRegistry`: `register(name, backend)` returns the disposer, duplicate names throw; `get(name)` throws `backend-not-found` for unknown names) and data-form mounting (`mount(form, facility)` over the merge-extensible `StorageForms` map, into which `dsh-domain` merges the `domain` key; unmounted access throws `form-not-mounted`). The signature text lives in `packages/storage/storage/src/index.ts` and `src/registry.ts`.
|
||||
|
||||
**Multiple backends stay mounted side by side**; which backend serves a domain is `dsh-domain`'s configuration (below), never a global either-or. Disposer semantics = remove the name from the table; closing the backend itself belongs to the backend package's effect closure, unregister first then close.
|
||||
|
||||
A backend is one **medium owner** (a file-tree root / one db file) exposing primitives through **data-shape facets** — only `kv` this phase; the session migration adds `log` (see the migration section). A facet is an optional member: absence means the backend cannot serve that shape, and resolution fails loud. The `kv` facet's primitive surface: `open(descriptor)` (descriptor = name/version/table list/global flag, with names and table names restricted to `^[a-z][a-z0-9_]*$` doubling as file-name and SQL-identifier segments) returns a unit exposing `loadAll` / `putRecord` / `deleteRecord` (missing key is a no-op) / `setGlobal` / `close` (idempotent); values are opaque JSON to the backend. The normative text (with per-method JSDoc) is `packages/storage/storage/src/backend.ts`.
|
||||
|
||||
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
|
||||
|
||||
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
|
||||
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
|
||||
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
|
||||
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
|
||||
5. `deleteRecord` is idempotent; `putRecord` overwrites.
|
||||
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
|
||||
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
|
||||
|
||||
The error vocabulary is `StorageError` with a code discriminant: `backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed` (`packages/storage/storage/src/error.ts`).
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config is `root` only (required, no default, schemastery); apply registers backend `json` inside `ctx.effect()`, and the disposer unregisters the name before `backend.close()`.
|
||||
|
||||
- Layout `<root>/<unitName>.json`, one file per unit; directory 0o700, files 0o600.
|
||||
- File format (version stamp in the header; the file is always the current net state, `JSON.stringify(…, null, 2)` human-readable — that legibility is this backend's reason to exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- Writes: every write primitive = full serialization of the in-memory state → temp write + fsync → atomic rename publish (the Windows variant follows session-persistence-jsonl's win32 path). Memory is authoritative, disk is its projection.
|
||||
- `loadAll`: parse the whole file at open; a missing `unit` header, non-object tables, etc. → `malformed-medium`. A missing file = an empty unit, materialized on first write.
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`.
|
||||
|
||||
- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables.
|
||||
- Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`.
|
||||
- DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches 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 versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
|
||||
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
|
||||
|
||||
### `dsh-domain`: the domain data form
|
||||
|
||||
A single implementation, not abstracted; consumers depend on this layer only and never touch backends directly.
|
||||
|
||||
```ts ignore-check
|
||||
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 unmount order: dispose each domain first (drain its write chain), then remove the name from the hub — in-flight writes still emit `domain/changed` during the drain, and the event-consistency invariant resolves domains back through the facility, so the name must stay resolvable at that point.)
|
||||
|
||||
Domain declarations (the spec object is defined and exported by the package that owns the domain — the single source of type and runtime truth; schemas use zod with `z.infer` deriving the types without re-declaration — the record model projects into RPC wire schemas next phase and the wire boundary is all zod; schemastery still owns plugin Config only):
|
||||
|
||||
```ts ignore-check
|
||||
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)` exact semantics (sequential; any failing step fails the whole open):
|
||||
|
||||
1. A domain with this name already open → `DomainError('already-open')`.
|
||||
2. Backend name = `config.routes[spec.name] ?? config.backend`; `ctx.storage.backend.get(name)` (an unmounted name propagates `backend-not-found` — misconfiguration fails loud).
|
||||
3. Backend lacks the `kv` facet → `DomainError('facet-unsupported')`.
|
||||
4. `kv.open(descriptorOf(spec))` (the descriptor is a direct projection of the spec).
|
||||
5. `loadAll()`; every record passes `valueSchema.parse`, the global passes its schema (null takes `initial`, not persisted — first write materializes). A failure → `DomainError('invalid-record', { table, key })` (the durable boundary must validate; the write side does not re-validate).
|
||||
6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`.
|
||||
|
||||
```ts ignore-check
|
||||
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')
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
|
||||
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
|
||||
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
|
||||
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
|
||||
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
|
||||
|
||||
### Future work: session-side deletion (design settled, not implemented this phase)
|
||||
|
||||
This section is the settled construction spec; the implementation phase changes code only, not semantics. No session-persistence file is modified this phase.
|
||||
|
||||
```ts ignore-check
|
||||
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 backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject.
|
||||
- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject.
|
||||
- After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable.
|
||||
|
||||
Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules):
|
||||
|
||||
| Check (in order) | On failure |
|
||||
| --- | --- |
|
||||
| No target (the whole subtree when recursive) is running in `ctx.sessions` | throw, delete nothing; callers cancel first then delete — the persistence layer never reaches back into the runtime |
|
||||
| Non-recursive: the target has no descendants (descendants = the `parentSessionId` transitive closure, derived from `list()` headers) | throw: by default only leaves are deletable; `recursive: true` opts into recursion |
|
||||
| Recursive order is bottom-up (leaves → root) | — a mid-way crash leaves only "half the subtree deleted, ancestors intact"; re-running the same delete converges, and no dangling parent exists at any moment |
|
||||
| Some id in the cascade is already gone from disk | skip (idempotent resumption); any other error aborts |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
The package owns the `WorkspaceId` brand and exposes `ctx.workspace`. The record key is a generated uuid — path is not the key: normalization rewrites it, and reference anchors must be stable.
|
||||
|
||||
```ts ignore-check
|
||||
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 canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side.
|
||||
- **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace.
|
||||
- Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas.
|
||||
- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record.
|
||||
|
||||
Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline):
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| A ledger id has no session on disk | filtered at `list()`/entity projection; pruned by the next mutate; no error (a normal product of deletion crash-consistency) |
|
||||
| A session's cwd matches a workspace but is not in the ledger | not owned: no merging, no adoption. The GUI may later build an "orphan sessions" area (orphans = the complement of all ledgers) |
|
||||
| One session in two ledgers | structurally blocked on the write side (attach check); detected at load → throw (externally hand-edited data, never masked) |
|
||||
| The workspace directory does not exist | record and ledger stay; `status()` = `'missing-dir'`; the storage layer never auto-deletes (the directory may only be temporarily moved) |
|
||||
|
||||
### Reuse and the session-backend migration outlook
|
||||
|
||||
**Long-term direction**: the pure medium operations inside session-persistence's JSONL/SQLite backends sink into `dsh-storage` backends (the session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file/db operation layer beneath them does). The motive for reuse: the medium layer is all filesystem operations, database calls, and cross-platform grit (Windows permission and atomic-publish variants, fsync semantics, exclusive file creation…), which should be written once; business semantics (how a session appends, when, and what) stay above — while "did this append complete correctly underneath" (durability/atomicity/platform correctness) is the lower layer's responsibility, and the responsibility boundary is the facet primitive contract. The backend interface is therefore designed as **medium owner + data-shape facets**: a session log is an append-only stream, a different shape from KV — forcing them into one set of primitives would deform both, so facets split them (`kv` this phase, `log` at migration) while sharing the medium and its lifecycle.
|
||||
|
||||
The current reuse audit (an account already legible before the migration):
|
||||
|
||||
| Existing session-persistence logic | Nature | Disposition |
|
||||
| --- | --- | --- |
|
||||
| JSONL: temp write + fsync + link/unlink atomic publish, 0o700/0o600 permissions, Windows variant (win32.ts) | pure medium | copied by `dsh-storage-json` this phase (whole-file atomic rewrite is the same protocol); becomes the shared implementation at migration |
|
||||
| JSONL: line-append, first-line header fast read, zstd per-frame compression | log shape | stays put; moves into the `log` facet at migration |
|
||||
| SQLite: openDatabase (mkdir/exclusive create/PRAGMA sequence/user_version check) | pure medium | copied by `dsh-storage-sqlite` this phase — the two openDatabase copies are already near line-identical and this group is the third user; copy now, extract at migration |
|
||||
| SQLite: events/sessions schema, same-transaction materialization | log shape | stays put; moves into the `log` facet at migration |
|
||||
| coordinator (per-id write chain, lazy materialization, crash repair, flush barrier) | session semantics | never sinks — event-log domain logic whose counterpart here is the domain layer's write chain; each owns its own |
|
||||
| encodeSegment (id-to-path escaping) | medium utility | unused on the domain side (keys never reach paths); sinks together with the `log` facet (one file per session) at migration |
|
||||
|
||||
**This phase does not touch session-persistence's medium code** (only the delete primitive is added); the table above is the migration-phase work list and the design evidence that the backend interface must accommodate the log shape.
|
||||
|
||||
### Test matrix
|
||||
|
||||
| Suite | Coverage | Backends |
|
||||
| --- | --- | --- |
|
||||
| backend contract (shared suite, written once, run on both) | the seven contract clauses + version rejection + close idempotence | json, sqlite (`:memory:` + temp dirs) |
|
||||
| registry/mount | duplicate registration, unmounted access, disposer removal | — |
|
||||
| domain layer | the six open steps, schema rejection, update serialization (concurrent interleaving stress), `domain/changed` per record, global initial-value lazy materialization, routing and `facet-unsupported` | either (json) |
|
||||
| workspace | create/uniqueness/realpath, attach checks (including rejection when sessionPersistence is absent), the four consistency-doctrine cases | mock domain or json |
|
||||
| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl, sqlite |
|
||||
|
||||
Snapshots: no model-visible or assembly surface this phase, none added; next phase's RPC wiring brings them with the `workspace.*` domain.
|
||||
|
||||
### Out-of-scope list
|
||||
|
||||
| Not doing | Trigger | Rework point | Groundwork |
|
||||
| --- | --- | --- | --- |
|
||||
| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with |
|
||||
| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already |
|
||||
| Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only |
|
||||
| Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process |
|
||||
| Data migration | model changes after the first tagged release | version-driven per-domain migration | versions are on the medium from day one |
|
||||
| Large-table performance | a thousand-record domain routed to json | point `routes` at sqlite, migrate the data by hand once | routing is configuration; consumers unchanged |
|
||||
| Multi-segment keys | a real two-segment consumer appears (per-workspace per-session dimension data) | key generics become tuples, SQLite composite primary keys, JSON nested levels | single-level tables are the one-segment special case; no arbitrary-depth nesting; no string-concatenated keys |
|
||||
| The scope dimension | a "one per workspace" domain appears and composite keys cannot express it | DomainSpec gains a scope declaration + a scope segment in file names (encodeSegment) | the name character set is already restricted; file names cannot collide |
|
||||
| Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — |
|
||||
| Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow |
|
||||
| Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — |
|
||||
| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Reusing session-persistence's coordinator/backends**: event-log semantics (append-only, turn crash repair, lazy materialization) do not match KV overwrite semantics; only the layering idea is borrowed (a coordination layer owns write ordering, backends implement minimal primitives).
|
||||
- **A workspace-specific storage package, seam extracted later**: the second consumer (the session sidecar) is already foreseeable; generalizing later means touching the interface twice.
|
||||
- **Merging domain and storage into one layer**: backends would be forced to touch schema validation, change events, and write serialization — domain concerns; split apart, storage backends implement only opaque primitives (the smallest replaceable surface) while the single domain implementation concentrates all domain logic (zod/events/serialization written once, not doubled per backend).
|
||||
- **JSON backend as jsonl append + tombstones + compaction**: temp+fsync+rename crash safety is equivalent to append; rewriting keeps the file the net current state, human-readable, with no folding/compaction/torn-line tolerance; at domain scale a full rewrite costs the same as appending a line.
|
||||
- **JSON one file per table**: under whole-file rewrites the file granularity does not affect write cost; merging per domain means fewer files and gives the global singleton a home.
|
||||
- **SQLite storing a whole domain as one blob row**: any single-record change rewrites the whole domain, forfeiting per-key precise updates — SQLite's only edge over JSON reduced to zero.
|
||||
- **SQLite generating typed columns from the schema**: a DDL generator is over-engineering; document-per-row suffices, revisit when real query needs appear.
|
||||
- **One sqlite db file per domain**: contrary to the repository's one-database-many-tables convention.
|
||||
- **A single whole-store backend choice (the session-persistence single-slot pattern)**: the initial design; changed to coexisting backends + configured routing because the hub will carry multiple data forms whose backend preferences (human-readable vs high-frequency point updates) are bound to diverge — a single slot forces the coarse "swap everything + hand-migrate data" move. The cost is one extra name lookup, backed by fail-loud.
|
||||
- **path as the workspace key**: normalization/symlink resolution rewrites the path; reference anchors must be stable.
|
||||
- **Ownership derived from cwd (or merged with the ledger)**: two sources of truth; cwd cannot express ordering; ownership is a workspace-side fact to begin with.
|
||||
- **Change events carrying the old value**: the repository's change-event convention is "new snapshot + operation discriminant" (the sole exception, fs's before/after, is a method return value rather than an event, because the old value is unrecoverable afterwards and has a diff consumer); consumers needing diffs hold their own previous snapshot.
|
||||
- **Delete auto-cancelling a running session**: the persistence/orchestration layer reaching back into the runtime dirties the layering; cancel already exists, callers compose it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine).
|
||||
- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work).
|
||||
- Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase).
|
||||
- No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The repository's first push-mode change event on a persistence surface** (session-persistence polls revisions): the shape has the `goal/changed` template, but "the storage layer emits events" is a new precedent, validated only when next phase's RPC consumes it.
|
||||
- **The JSON backend's whole-unit rewrite scale premise**: if the second consumer (the session sidecar) lands on the JSON backend at thousand-record scale before being routed to SQLite, the rewrite cost surfaces earlier than expected; the mitigation is exactly `routes` pointing at sqlite.
|
||||
- **The deletion orchestration's weak dependency on `ctx.sessions`**: a headless assembly without the runtime registry treats it as "no hot sessions", leaving a window (an external process running the session); multi-process is already out of scope, accepted.
|
||||
- **Facet generalization designed against the future `log` facet without implementing it this phase**: a "reserved shape does not fit" risk; mitigated by organizing both backends' medium code in the sinkable shape from the reuse audit, so when the `log` facet lands only the facet layer moves.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文
|
||||
|
||||
## 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-storage-domain` | `packages/storage/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。`Storage` service 挂 `ctx.storage`,两个面:`backend`(`BackendRegistry`:`register(name, backend)` 返回 disposer、重名 throw;`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map,`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts` 与 `src/registry.ts`。
|
||||
|
||||
**多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置(见下),不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。
|
||||
|
||||
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud。`kv` facet 的原语面:`open(descriptor)`(descriptor = 名字/版本/表名清单/有无 global,名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unit,unit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op)/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文(含逐方法 JSDoc)在 `packages/storage/storage/src/backend.ts`。
|
||||
|
||||
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')`。
|
||||
|
||||
错误词汇是带 code 判别的 `StorageError`,码表:`backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed`(`packages/storage/storage/src/error.ts`)。
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config 仅 `root`(必填无默认,schemastery);apply 在 `ctx.effect()` 里注册后端 `json`,disposer 先摘名再 `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`
|
||||
|
||||
Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`);apply 同 json,注册后端 `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 ignore-check
|
||||
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 ignore-check
|
||||
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 ignore-check
|
||||
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 `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。
|
||||
|
||||
### Future work:session 侧删除(设计定案,本期不实施)
|
||||
|
||||
本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。
|
||||
|
||||
```ts ignore-check
|
||||
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 ignore-check
|
||||
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 层。
|
||||
Reference in New Issue
Block a user