diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml new file mode 100644 index 0000000000..9e25572a8f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.i18n.yaml @@ -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: 96299afae94245c1441643407a21a8fab0efcad5 +2026-07-24-domain-kv-storage-and-workspace.zh.md: 67921a4eec4d322aefc705042b6356b156755a7f diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md new file mode 100644 index 0000000000..96299afae9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md @@ -0,0 +1,413 @@ +# 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-domain` | `packages/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. + +```ts ignore-check +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(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[] +} +``` + +**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: + +```ts ignore-check +export interface StorageBackend { + readonly name: string + readonly kv?: KvFacet // 迁移期扩展:readonly log?: LogFacet + /** Drain in-flight writes and release the medium. Idempotent. */ + close(): Promise +} + +export interface KvFacet { + /** Open (create or load) one unit. Version mismatch / malformed medium → throw. */ + open(descriptor: KvUnitDescriptor): Promise +} + +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>; global: unknown | null }> + putRecord(table: string, key: string, value: unknown): Promise + deleteRecord(table: string, key: string): Promise // missing key = no-op + setGlobal(value: unknown): Promise + close(): Promise // idempotent +} +``` + +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')`. + +```ts ignore-check +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` + +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 `/.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": { "": {} } } +} +``` + +- 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__" ( + 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 { readonly schema: ZodType; readonly initial: G } +export interface DomainTableSpec { readonly valueSchema: ZodType } + +export interface DomainSpec { + readonly name: string // ^[a-z][a-z0-9_]*$ + readonly version: number + readonly global?: DomainGlobalSpec + readonly tables: Record> +} + +export function defineDomain(spec: S): S +export function domainTable(schema: ZodType): DomainTableSpec +``` + +`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 { + readonly name: string + readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 + table(name: N): KvTable, ValueOf> +} + +export interface KvTable { + get(key: K): V | undefined // 内存快照,同步 + entries(): IterableIterator<[K, V]> + keys(): IterableIterator + readonly size: number + put(key: K, value: V): Promise + delete(key: K): Promise // false = 本就不存在 + /** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */ + update(key: K, fn: (current: V) => V): Promise // 缺 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**: emitted after each write's durability resolves, one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); this is next phase's RPC push-frame event source: + +```ts ignore-check +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-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 +} +``` + +- 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 + +export const workspaceDomainSpec = defineDomain({ + name: 'workspace', version: 1, + tables: { workspaces: domainTable(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 + /** 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 + detachSession(sessionId: SessionId): Promise + /** 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 重建 + create(path: string, title?: string): Promise // realpath 后撞已有 → reject + get(id: WorkspaceId): Workspace | undefined + list(): Workspace[] + resolveByPath(path: string): Promise // 同 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. diff --git a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md index e6c2d69680..67921a4eec 100644 --- a/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md @@ -2,6 +2,8 @@ Status: proposed +[English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文 + ## Problem host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求: @@ -33,7 +35,7 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis 纯注册枢纽,自身不做 IO,无 Config。 -```ts +```ts ignore-check declare module 'cordis' { interface Context { storage: Storage } } export class Storage extends Service { @@ -61,7 +63,7 @@ export class BackendRegistry { 一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud: -```ts +```ts ignore-check export interface StorageBackend { readonly name: string readonly kv?: KvFacet // 迁移期扩展:readonly log?: LogFacet @@ -101,7 +103,7 @@ backend 契约(共享契约测试逐条断言,两后端同套件): 6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。 7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。 -```ts +```ts ignore-check export type StorageErrorCode = | 'backend-not-found' | 'form-not-mounted' | 'duplicate-backend' | 'duplicate-mount' | 'version-mismatch' | 'malformed-medium' | 'closed' @@ -110,17 +112,7 @@ 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() } - }) -} -``` +Config 仅 `root`(必填无默认,schemastery);apply 在 `ctx.effect()` 里注册后端 `json`,disposer 先摘名再 `backend.close()`。 - 布局 `/.json`,一 unit 一文件;目录 0o700、文件 0o600。 - 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由): @@ -138,13 +130,7 @@ export function apply(ctx: Context, config: Config) { ### `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', …) -``` +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`。 @@ -166,7 +152,7 @@ CREATE TABLE IF NOT EXISTS "u__
" ( 单实现不抽象;消费者只依赖这层,不直接触后端。 -```ts +```ts ignore-check export const Config = z.object({ backend: z.string().required(), // 默认后端名,必填 routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' } @@ -181,7 +167,7 @@ export function apply(ctx: Context, config: Config) { 域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config): -```ts +```ts ignore-check export interface DomainGlobalSpec { readonly schema: ZodType; readonly initial: G } export interface DomainTableSpec { readonly valueSchema: ZodType } @@ -205,7 +191,7 @@ export function domainTable(schema: ZodType): DomainTabl 5. `loadAll()`;每条记录 `valueSchema.parse`,global 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。 6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。 -```ts +```ts ignore-check export interface Domain { readonly name: string readonly global: { get(): G; set(value: G): Promise } // 仅当 spec.global 声明 @@ -232,7 +218,7 @@ export interface KvTable { - **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。 - **变更事件**:每次写落盘 resolve 后 emit,逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);此为下期 RPC 推帧的事件源: -```ts +```ts ignore-check declare module 'cordis' { interface Events { /** @@ -261,7 +247,7 @@ export class DomainError extends Error { readonly code: DomainErrorCode } 本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。 -```ts +```ts ignore-check export abstract class SessionPersistence extends Service { /** * Permanently delete one session's stored log. @@ -290,7 +276,7 @@ export abstract class SessionPersistence extends Service { 包拥有 `WorkspaceId` brand,暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key:规范化会改写它,引用锚点必须稳定。 -```ts +```ts ignore-check export type WorkspaceId = Branded<'WorkspaceId'> export function WorkspaceId(id: string): WorkspaceId diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..de2599b579 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,13 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_storage["storage"] + svc_storage["ctx.storage
Non-session storage hub"] + pkg_storage_json["storage-json"] + pkg_storage_sqlite["storage-sqlite"] + pkg_domain["domain"] + pkg_workspace["workspace"] + svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -166,6 +173,9 @@ flowchart LR pkg_skill_local --> svc_skills pkg_spill --> svc_spillStore pkg_spill_local --> svc_spillStore + pkg_storage --> svc_storage + pkg_storage_json --> svc_storage + pkg_storage_sqlite --> svc_storage pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents @@ -185,6 +195,7 @@ flowchart LR pkg_web_search_perplexity --> svc_web pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows + pkg_workspace --> svc_workspace svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -235,6 +246,8 @@ flowchart LR svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy + svc_storage --> pkg_domain + svc_storage --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent svc_systemPrompt --> pkg_agent_loop @@ -276,6 +289,8 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`domain`](../packages/storage/domain), [`workspace`](../packages/workspace/workspace) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | +| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..6cce8b61f1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -369,6 +369,27 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-domain` + +Requires: `storage` + +```ts config-catalog +/** + * Plugin config. Which backend serves which domain is decided here, not + * globally on the hub: `backend` is the default route and `routes` overrides + * it per domain name. A route naming an unregistered backend fails loud at + * `open` with `backend-not-found`. + */ +export interface Config { + /** Default backend name for every domain without an explicit route. Required: there is no universally correct medium. */ + backend: string + /** Per-domain overrides: domain name → backend name. */ + routes?: Record +} +``` + +Source: [`packages/storage/domain/src/index.ts:45`](../packages/storage/domain/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -1155,6 +1176,63 @@ export interface Config { Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts) +## `@deepseek-ai/dsh-storage-json` + +Requires: `storage` + +```ts config-catalog +/** + * Plugin configuration. + * `root` has NO default on purpose: a `process.cwd()` fallback would scatter + * unit files wherever the process happens to start; assemblies state the + * location explicitly. + */ +export interface Config { + /** Directory holding one `.json` file per unit. */ + root: string +} +``` + +Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts) + +## `@deepseek-ai/dsh-storage-sqlite` + +Requires: `storage` + +```ts config-catalog +/** Plugin configuration. */ +export interface Config { + /** + * Filesystem path to the SQLite database file. The special value `:memory:` + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing + * database fail the open. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. + */ + path: string + /** + * SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick + * a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems + * where WAL's shared-memory files do not work (network mounts). See + * {@link JournalMode}. + */ + journalMode?: JournalMode +} + +/** + * Journal modes the backend will run under. `wal` is the default; the + * rollback-journal modes (`delete`/`truncate`/`persist`) exist for + * filesystems where WAL's shared-memory files do not work (network mounts). + * `memory`/`off` are excluded: dropping journal durability silently + * contradicts the durability clause of the KV backend contract. + */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +``` + +Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/storage-sqlite/src/index.ts) + ## `@deepseek-ai/dsh-subagent-acp` Requires: `subagents` @@ -1912,12 +1990,14 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) +- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) +- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) ## Seam packages (not directly loadable) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..3019f059d7 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -489,6 +489,26 @@ A command was registered or unregistered. This is an unfiltered registry notific Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts) +## `domain/*` + +### `domain/changed` — emit + +A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability. Events of one domain arrive in its write-chain order. + +```ts cordis-catalog +/** + * A domain record or the global singleton changed, emitted once per write + * strictly after the backend acknowledged durability. Events of one + * domain arrive in its write-chain order. + * @param change - domain, table (`''` for global), key (`''` for global), + * operation discriminant, and on `put` the new snapshot. + * @mode emit + */ +'domain/changed'(change: DomainChanged): void +``` + +Source: [`packages/storage/domain/src/events.ts:46`](../../packages/storage/domain/src/events.ts) + ## `fs/*` ### `fs/edit-intent` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..a4d5f02162 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1353,6 +1353,30 @@ Types: [SaveTextSpill](../core-data-structures/spill.md) · [SpillRef](../core-d Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) +## `ctx.storage` — `Storage` + +The storage hub service. Backends register under `backend`; data forms mount under their `StorageForms` key and are reached as `ctx.storage.
`. + +```ts cordis-catalog +/** + * Mount a data-form facility on the hub. Mounting is an effect: the + * returned disposer unmounts the form. + * @param form - Form key declared in {@link StorageForms}. + * @param facility - The facility instance to expose. + * @returns the disposer that unmounts the form. + */ +mount(form: K, facility: StorageForms[K]): () => void + +/** + * Resolve a mounted data form. + * @param form - Form key declared in {@link StorageForms}. + * @returns the mounted facility. + */ +form(form: K): StorageForms[K] +``` + +Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts) + ## `ctx.subagents` — `SubagentService` Named provider registry and capability-checked start surface. @@ -1813,6 +1837,52 @@ Types: [WorkflowRun](../core-data-structures/workflow.md) · [WorkflowStartReque Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +## `ctx.workspace` — `WorkspaceRegistry` + +The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered. + +There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note). + +```ts cordis-catalog +/** + * Create a workspace over an existing directory. The path is canonicalized + * through `fs.realpath` first — a nonexistent path rejects with the + * original `ENOENT`, a path resolving to anything but a directory rejects, + * and a canonical path already owned by another workspace (including a + * symlink resolving to it) rejects. + * @param path - Directory the workspace points at; canonicalized before storing. + * @param title - Display title; defaults to `basename` of the canonical path. + * @returns the created workspace after durability. + */ +async create(path: string, title?: string): Promise + +/** + * Look up a workspace by id. + * @param id - The workspace id. + * @returns the workspace, or `undefined` when unknown. + */ +get(id: WorkspaceId): Workspace | undefined + +/** + * Snapshot of all workspaces, in load-then-creation order. + * @returns a fresh array of the cached entities. + */ +list(): Workspace[] + +/** + * Resolve a workspace by directory path, through the same `fs.realpath` + * canon as {@link create} (hence async). A path that does not exist rejects + * with the original error — a missing directory has no canonical form to + * compare (a workspace whose recorded directory vanished is only reachable + * by id; see `Workspace.status`). + * @param path - Directory path in any spelling (symlinks, `..`, trailing slash). + * @returns the owning workspace, or `undefined` when none matches. + */ +async resolveByPath(path: string): Promise +``` + +Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts) + ## Inherited `ctx` members (cordis core + loader/hmr/timer) The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..93fde6582c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,6 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | +| `domain/changed` | `emit` | [`packages/storage/domain/src/events.ts:46`](../packages/storage/domain/src/events.ts) | [`domain`](../packages/storage/domain) (`emit`) | [`domain`](../packages/storage/domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..5ede1d5d57 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -195,6 +195,12 @@ flowchart TD pkg_scripts["scripts"] pkg_telemetry["telemetry"] end + subgraph group_storage["packages/storage"] + pkg_domain["domain"] + pkg_storage["storage"] + pkg_storage_json["storage-json"] + pkg_storage_sqlite["storage-sqlite"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tool_tasks["tool-tasks"] @@ -205,6 +211,9 @@ flowchart TD pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] end + subgraph group_workspace["packages/workspace"] + pkg_workspace["workspace"] + end pkg_brand --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants @@ -230,6 +239,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_runtime --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_storage --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -250,6 +260,12 @@ flowchart TD pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths + pkg_domain --> pkg_invariants + pkg_domain --> pkg_storage + pkg_storage_json --> pkg_invariants + pkg_storage_json --> pkg_storage + pkg_storage_sqlite --> pkg_invariants + pkg_storage_sqlite --> pkg_storage pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -413,6 +429,12 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_domain + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -805,6 +827,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | @@ -812,6 +835,9 @@ flowchart TD | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | +| [`domain`](../packages/storage/domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -859,6 +885,7 @@ flowchart TD | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`domain`](../packages/storage/domain), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..f11f886eb2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -34,6 +34,8 @@ Packages live at `packages///`; groups are containers, while names r | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | +| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c3e4e1858b..6d45994f21 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -642,6 +642,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'storage', + summary: 'The storage hub service.', + methods: [ + { + signature: 'mount(form: K, facility: StorageForms[K]): () => void', + jsDoc: '/**\n * Mount a data-form facility on the hub. Mounting is an effect: the\n * returned disposer unmounts the form.\n * @param form - Form key declared in {@link StorageForms}.\n * @param facility - The facility instance to expose.\n * @returns the disposer that unmounts the form.\n */', + }, + { + signature: 'form(form: K): StorageForms[K]', + jsDoc: '/**\n * Resolve a mounted data form.\n * @param form - Form key declared in {@link StorageForms}.\n * @returns the mounted facility.\n */', + }, + ], + }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', @@ -846,6 +860,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'workspace', + summary: 'The workspace registry service.', + methods: [ + { + signature: 'async create(path: string, title?: string): Promise', + jsDoc: '/**\n * Create a workspace over an existing directory. The path is canonicalized\n * through `fs.realpath` first — a nonexistent path rejects with the\n * original `ENOENT`, a path resolving to anything but a directory rejects,\n * and a canonical path already owned by another workspace (including a\n * symlink resolving to it) rejects.\n * @param path - Directory the workspace points at; canonicalized before storing.\n * @param title - Display title; defaults to `basename` of the canonical path.\n * @returns the created workspace after durability.\n */', + }, + { + signature: 'get(id: WorkspaceId): Workspace | undefined', + jsDoc: '/**\n * Look up a workspace by id.\n * @param id - The workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */', + }, + { + signature: 'list(): Workspace[]', + jsDoc: '/**\n * Snapshot of all workspaces, in load-then-creation order.\n * @returns a fresh array of the cached entities.\n */', + }, + { + signature: 'async resolveByPath(path: string): Promise', + jsDoc: '/**\n * Resolve a workspace by directory path, through the same `fs.realpath`\n * canon as {@link create} (hence async). A path that does not exist rejects\n * with the original error — a missing directory has no canonical form to\n * compare (a workspace whose recorded directory vanished is only reachable\n * by id; see `Workspace.status`).\n * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).\n * @returns the owning workspace, or `undefined` when none matches.\n */', + }, + ], + }, ] /** Every harness event, sorted by name. */ @@ -997,6 +1033,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'A command was registered or unregistered.', }, + { + name: 'domain/changed', + mode: 'emit', + signature: '\'domain/changed\'(change: DomainChanged): void', + jsDoc: '/**\n * A domain record or the global singleton changed, emitted once per write\n * strictly after the backend acknowledged durability. Events of one\n * domain arrive in its write-chain order.\n * @param change - domain, table (`\'\'` for global), key (`\'\'` for global),\n * operation discriminant, and on `put` the new snapshot.\n * @mode emit\n */', + summary: 'A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability.', + }, { name: 'fs/edit-intent', mode: 'waterfall', @@ -1999,6 +2042,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SpillSource', declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}', }, + { + name: 'StorageForms', + declaration: 'export interface StorageForms {\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', @@ -2335,6 +2382,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkflowStopReason', declaration: 'export type WorkflowStopReason = \'completed\' | \'cancelled\' | \'error\';', }, + { + name: 'Workspace', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + }, ] /** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */ diff --git a/packages/storage/README.md b/packages/storage/README.md new file mode 100644 index 0000000000..f112cdb5a0 --- /dev/null +++ b/packages/storage/README.md @@ -0,0 +1,12 @@ +# storage/ — non-session storage family + +The storage family persists everything that is not a session event log: a hub where named backends and typed data forms meet. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). + +| Package | Role | ctx key | +|---|---|---| +| `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` | +| `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` | +| `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` | +| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | mounts `ctx.storage.domain` | + +Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Consumers never touch backends directly — they open declared domains through the domain form. diff --git a/packages/storage/domain/README.md b/packages/storage/domain/README.md index eb955a4735..38793ad9c7 100644 --- a/packages/storage/domain/README.md +++ b/packages/storage/domain/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-domain -Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, land durably on the routed backend, then emit `domain/changed`. +Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). @@ -13,9 +13,21 @@ Design rationale, open semantics, and the storage/domain layer split live in the ## Model Experience -No model-visible surface: the package registers no tools, injects no prompts, and emits no context. Token and KV-cache cost are zero. +### Durable domain state + +#### What the model sees + +Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storage.domain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface. + +#### Token effect + +Zero. No text from this package enters any model request. + +#### KV Cache effect + +Independent: domain reads and writes never touch request prefixes, so nothing here can invalidate provider cache reuse. ## Known Limitations and Deferred Work -- Single-process only: `domain/changed` is an in-process event; cross-process observation (GUI reconnect) is deferred to the revision pattern noted in the Agent Note's non-goals. -- No cross-table transactions, secondary indexes, or multi-segment keys; triggers and rework points are tabled in the Agent Note. +- **Single-process change visibility** — `domain/changed` is an in-process event; a second host process or a reconnecting GUI observes no changes until the cross-process revision pattern deferred in the Agent Note lands. +- **No cross-table transactions, secondary indexes, or multi-segment keys** — each write touches one record; triggers and rework points for these extensions are tabled in the Agent Note's deferred-work list. diff --git a/packages/storage/domain/src/domain.ts b/packages/storage/domain/src/domain.ts index a0796ff628..aa29348dfc 100644 --- a/packages/storage/domain/src/domain.ts +++ b/packages/storage/domain/src/domain.ts @@ -147,7 +147,9 @@ export class DomainImpl { * @param ctx - Context that carries `domain/changed` emissions. * @param spec - The domain declaration. * @param unit - The opened backend unit; this instance owns its lifecycle. - * @param records - Validated per-table records from the unit's `loadAll`. + * @param records - Validated records from the unit's `loadAll`, one entry + * per declared table (empty maps included) — the facility builds it from + * the spec, so the entry set IS the table set. * @param globalValue - Validated stored global, or the spec's `initial` * when the medium held none; `undefined` when the spec declares no global. */ @@ -166,8 +168,8 @@ export class DomainImpl { assertReadable: () => this.assertReadable(), emitChanged: (change) => this.ctx.emit('domain/changed', change), } - for (const table of Object.keys(spec.tables)) { - this.tables.set(table, new KvTableImpl(host, table, records.get(table) ?? new Map())) + for (const [table, tableRecords] of records) { + this.tables.set(table, new KvTableImpl(host, table, tableRecords)) } if (spec.global !== undefined) { this.globalValue = globalValue diff --git a/packages/storage/domain/src/index.ts b/packages/storage/domain/src/index.ts index 18eb2e7ed5..059f6076f2 100644 --- a/packages/storage/domain/src/index.ts +++ b/packages/storage/domain/src/index.ts @@ -119,8 +119,10 @@ export class DomainFacility { ? spec.global.initial : parseRecord(spec.name, '', '', () => spec.global!.schema.parse(snapshot.global)) const domain = new DomainImpl(this.ctx, spec, unit, tables, globalValue) - this.domains.set(spec.name, domain) + // The open-domain table entry is itself the effect: registration and + // the drain-then-unlist teardown live in one closure. this.ctx.effect(() => { + this.domains.set(spec.name, domain) return async () => { // Drain before unlisting: writes landing during the drain still // emit domain/changed, and the domain must stay resolvable (the @@ -139,7 +141,9 @@ export class DomainFacility { throw error } } catch (error) { - if (!this.domains.has(spec.name)) this.reserved.delete(spec.name) + // Any failure means the effect never registered (nothing can throw + // after it), so releasing the name reservation is unconditional. + this.reserved.delete(spec.name) throw error } } diff --git a/packages/storage/domain/tests/domain.spec.ts b/packages/storage/domain/tests/domain.spec.ts index 6fc810ca5a..909c983061 100644 --- a/packages/storage/domain/tests/domain.spec.ts +++ b/packages/storage/domain/tests/domain.spec.ts @@ -76,6 +76,35 @@ describe('DomainFacility.open', () => { await expect(facility.open(spec)).rejects.toMatchObject({ code: 'facet-unsupported' }) }) + it('falls back to the default backend when no route table is configured', async () => { + // A second, unmounted facility whose config omits `routes` entirely + // (exactOptionalPropertyTypes forbids an explicit undefined). Opening + // emits no events, so the mounted facility's invariant never consults it. + const { ctx } = await harness() + const routeless = new DomainFacility(ctx, { backend: 'memory' }) + await expect(routeless.open(bareSpec)).resolves.toBeDefined() + }) + + it('treats a table key the backend omitted from loadAll as empty', async () => { + // A sparse backend: loadAll omits declared table keys entirely instead of + // returning them as empty objects. + const { ctx, facility } = await harness({ config: { backend: 'sparse' } }) + ctx.storage.backend.register('sparse', { + kv: { + open: async () => ({ + loadAll: async () => ({ tables: {}, global: null }), + putRecord: async () => {}, + deleteRecord: async () => {}, + setGlobal: async () => {}, + close: async () => {}, + }), + }, + close: async () => {}, + }) + const domain = await facility.open(bareSpec) + expect(domain.table('rows').size).toBe(0) + }) + it('rejects stored records that fail their schema, naming table and key', async () => { const pool = new MemoryMediaPool() { @@ -112,6 +141,33 @@ describe('DomainFacility.open', () => { }) }) +describe('plugin apply', () => { + it('mounts the facility as ctx.storage.domain through the plugin effect', async () => { + const ctx = new Context() + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const DomainPlugin = await import('../src/index.ts') + const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' }) + expect(ctx.storage.domain).toBeInstanceOf(DomainFacility) + await fiber.dispose() + expect(() => ctx.storage.form('domain')).toThrow(/not mounted/) + }) +}) + +describe('table and snapshot reads', () => { + it('serves entries, keys, and size as stable snapshots; unknown table names throw', async () => { + const { facility } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + expect(table.size).toBe(2) + expect([...table.keys()].sort()).toEqual(['a', 'b']) + expect(new Map(table.entries()).get('a')).toEqual({ label: 'x', count: 1 }) + expect(() => domain.table('nope' as never)).toThrow(/declares no table/) + }) +}) + describe('KvTable writes', () => { it('serializes concurrent updates on one key without losing increments', async () => { const { facility } = await harness() diff --git a/packages/storage/domain/tests/invariant.spec.ts b/packages/storage/domain/tests/invariant.spec.ts new file mode 100644 index 0000000000..00b9329464 --- /dev/null +++ b/packages/storage/domain/tests/invariant.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import Storage from '@deepseek-ai/dsh-storage' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import * as DomainInvariantCompanion from '@deepseek-ai/dsh-domain/invariant' +import { DomainFacility, defineDomain, domainTable } from '../src/index.ts' +import type { DomainChanged } from '../src/events.ts' +import { MemoryStorageBackend } from './helpers/memory-backend.ts' + +const itemSchema = z.object({ n: z.number() }) +type Item = z.infer + +const spec = defineDomain({ + name: 'inv', + version: 1, + global: { schema: itemSchema, initial: { n: 0 } }, + tables: { rows: domainTable(itemSchema) }, +}) + +async function setup() { + const ctx = new Context() + await ctx.plugin(Storage) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(DomainInvariantCompanion) + ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + return { ctx, facility } +} + +const invariantViolation = expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-domain', +}) + +describe('domain change-event invariants', () => { + it('accepts every write shape emitted by the real write paths', async () => { + const { facility } = await setup() + const domain = await facility.open(spec) + const rows = domain.table('rows') + await rows.put('a', { n: 1 }) + await rows.update('a', (current) => ({ n: current.n + 1 })) + await expect(rows.delete('a')).resolves.toBe(true) + await domain.global.set({ n: 5 }) + }) + + it('rejects an event for a domain that is not open', async () => { + const { ctx } = await setup() + expect(() => ctx.emit('domain/changed', { + domain: 'ghost', table: 'rows', key: 'a', operation: 'put', value: { n: 1 }, + })).toThrow(invariantViolation) + }) + + it('rejects a put event whose value is not the in-memory record', async () => { + const { ctx, facility } = await setup() + const domain = await facility.open(spec) + await domain.table('rows').put('a', { n: 1 }) + expect(() => ctx.emit('domain/changed', { + domain: 'inv', table: 'rows', key: 'a', operation: 'put', value: { n: 999 }, + })).toThrow(invariantViolation) + }) + + it('rejects a deletion event while the record is still in memory', async () => { + const { ctx, facility } = await setup() + const domain = await facility.open(spec) + await domain.table('rows').put('a', { n: 1 }) + expect(() => ctx.emit('domain/changed', { + domain: 'inv', table: 'rows', key: 'a', operation: 'deleted', + })).toThrow(invariantViolation) + }) + + it('rejects a global event whose value is not the in-memory global', async () => { + const { ctx, facility } = await setup() + await facility.open(spec) + expect(() => ctx.emit('domain/changed', { + domain: 'inv', table: '', key: '', operation: 'put', value: { n: 42 }, + })).toThrow(invariantViolation) + }) + + it('tolerates operations outside the closed union without failing falsely', async () => { + const { ctx, facility } = await setup() + const domain = await facility.open(spec) + await domain.table('rows').put('a', { n: 1 }) + // Merge-hostile input: the closed union's satisfies-never default arm is + // unreachable in typed code; an untyped emit must not crash the check. + expect(() => ctx.emit('domain/changed', { + domain: 'inv', table: 'rows', key: 'a', operation: 'exotic', + } as unknown as DomainChanged)).not.toThrow() + }) +}) diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 4ac71dc0a9..5cf0fc9d61 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -16,7 +16,19 @@ JSON backend for the [storage hub](../storage/README.md): one human-readable ` => { - if (this.closed) return Promise.reject(new StorageError('closed', 'json backend is closed')) + // The body up to the first await runs synchronously, so the opening-slot + // reservation below still excludes a concurrent open of the same unit. + open: async (descriptor: KvUnitDescriptor): Promise => { + if (this.closed) throw new StorageError('closed', 'json backend is closed') validateDescriptor(descriptor) if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) { // Double-open is a caller bug, not a medium condition. - return Promise.reject( - new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`), - ) + throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`) } const opening = this.openUnit(descriptor) this.opening.set(descriptor.name, opening) diff --git a/packages/storage/storage-json/src/unit.ts b/packages/storage/storage-json/src/unit.ts index 43a986a637..6677abacf2 100644 --- a/packages/storage/storage-json/src/unit.ts +++ b/packages/storage/storage-json/src/unit.ts @@ -14,7 +14,13 @@ import { writeAtomic } from './atomic.ts' import { parse, serialize } from './format.ts' import type { UnitState } from './format.ts' -/** Open (load or lazily create) one unit backed by `path`. */ +/** + * Open (load or lazily create) one unit backed by `path`. + * @param descriptor - Static identity and shape of the unit. + * @param path - Absolute unit file path under the backend root. + * @param onClose - Backend callback releasing the unit's open-slot. + * @returns the opened unit. + */ export async function openJsonUnit( descriptor: KvUnitDescriptor, path: string, diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 709e754f3c..83ed948fd0 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -1,9 +1,13 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Storage from '@deepseek-ai/dsh-storage' +import InvariantService from '@deepseek-ai/dsh-invariants' import { runKvBackendContract } from '../../storage/tests/contract.ts' -import { JsonStorageBackend } from '../src/index.ts' +import { Config, JsonStorageBackend, apply } from '../src/index.ts' +import * as InvariantCompanion from '../src/invariant.ts' const roots: string[] = [] @@ -83,15 +87,17 @@ describe('json backend specifics', () => { const backend = new JsonStorageBackend(root) const unit = await backend.kv.open(descriptor) await unit.putRecord('t', 'k', { v: 'committed' }) - // Make the next publish fail: replace the unit file's parent with an - // unwritable directory path via chmod. - const { chmod } = await import('node:fs/promises') + await unit.setGlobal({ g: 'committed' }) + // Make every publish fail: revoke write permission on the root. await chmod(root, 0o500) await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() + await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() + await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() await chmod(root, 0o700) const snapshot = await unit.loadAll() expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } }) + expect(snapshot.global).toEqual({ g: 'committed' }) // The next successful publish must not carry rejected writes to disk. await unit.putRecord('t', 'k3', { v: 'later' }) const text = await readFile(join(root, 'shape.json'), 'utf8') @@ -99,6 +105,102 @@ describe('json backend specifics', () => { await backend.close() }) + it('rejects undeclared table and global access as caller errors', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open({ name: 'shape', version: 1, tables: ['t'], hasGlobal: false }) + await expect(unit.putRecord('undeclared', 'k', {})).rejects.toThrow(/does not declare table/) + await expect(unit.setGlobal({})).rejects.toThrow(/does not declare a global slot/) + await backend.close() + }) + + it('rejects invalid unit and table names', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + await expect(backend.kv.open({ ...descriptor, name: 'Bad-Name' })).rejects.toMatchObject({ + name: 'StorageError', + code: 'malformed-medium', + }) + await expect(backend.kv.open({ ...descriptor, tables: ['ok', 'not ok'] })).rejects.toMatchObject({ + name: 'StorageError', + code: 'malformed-medium', + }) + await backend.close() + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'closed' }) + }) + + it('opens a file missing a declared table as that table empty', async () => { + const root = await freshRoot() + await writeFile( + join(root, 'contract_unit.json'), + JSON.stringify({ unit: { name: 'contract_unit', version: 3 }, global: null, tables: { alpha: { k: 1 } } }), + 'utf8', + ) + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open({ name: 'contract_unit', version: 3, tables: ['alpha', 'beta'], hasGlobal: true }) + const snapshot = await unit.loadAll() + expect(snapshot.tables['alpha']).toEqual({ k: 1 }) + expect(snapshot.tables['beta']).toEqual({}) + await backend.close() + }) + + it('propagates non-ENOENT read failures', async () => { + const root = await freshRoot() + const { mkdir } = await import('node:fs/promises') + // A directory where the unit file should be: readFile fails with EISDIR. + await mkdir(join(root, 'shape.json')) + const backend = new JsonStorageBackend(root) + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'EISDIR' }) + await backend.close() + }) + + it('rejects malformed table shapes and foreign versions distinctly', async () => { + const root = await freshRoot() + await writeFile( + join(root, 'shape.json'), + JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null, tables: { t: ['not', 'an', 'object'] } }), + 'utf8', + ) + const backend = new JsonStorageBackend(root) + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' }) + + await writeFile( + join(root, 'shape.json'), + JSON.stringify({ unit: { name: 'shape', version: 9 }, global: null, tables: {} }), + 'utf8', + ) + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'version-mismatch' }) + + await writeFile(join(root, 'shape.json'), JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null }), 'utf8') + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' }) + + await writeFile(join(root, 'shape.json'), JSON.stringify('just a string'), 'utf8') + await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' }) + await backend.close() + }) + + it('registers on the hub via apply and closes on dispose', async () => { + const root = await freshRoot() + const ctx = new Context() + await ctx.plugin(Storage) + const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root }) + const backend = ctx.storage.backend.get('json') + const unit = await backend.kv!.open(descriptor) + await unit.putRecord('t', 'k', { v: 1 }) + await fiber.dispose() + expect(() => ctx.storage.backend.get('json')).toThrow() + await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' }) + }) + + it('registers the invariant companion and disposes cleanly', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(InvariantCompanion) + // Disposal releases the reservation: a fresh mount succeeds. + await fiber.dispose() + await ctx.plugin(InvariantCompanion) + }) + it('close drains in-flight writes and blocks in-flight opens', async () => { const root = await freshRoot() const backend = new JsonStorageBackend(root) diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index b76515da3f..272b27f979 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -19,17 +19,19 @@ interface Config { ## Model Experience -### What the model sees +### Stored domain records -Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data for host-side consumers. +#### What the model sees -### Token effect +Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data (workspace records, future session sidecar metadata) behind `ctx.storage` for host-side consumers only. + +#### Token effect Zero live-request tokens. -### KV Cache effect +#### KV Cache effect -None — no live request prefixes are touched. +None — the backend never touches live request prefixes. ## Known Limitations and Deferred Work diff --git a/packages/storage/storage-sqlite/tests/invariant.spec.ts b/packages/storage/storage-sqlite/tests/invariant.spec.ts new file mode 100644 index 0000000000..0c23906ca4 --- /dev/null +++ b/packages/storage/storage-sqlite/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as StorageSqliteInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers under the package name with an explained-empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(StorageSqliteInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index e952d93f32..e269de25dd 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -1,10 +1,13 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, rm } from 'node:fs/promises' +import { Context } from 'cordis' +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' +import Storage from '@deepseek-ai/dsh-storage' import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage' import { runKvBackendContract } from '../../storage/tests/contract.ts' +import * as StorageSqlite from '../src/index.ts' import { Config, SqliteStorageBackend, STORAGE_SQLITE_SCHEMA_VERSION } from '../src/index.ts' /** Mirror the loader: resolve schemastery defaults before construction. */ @@ -168,6 +171,65 @@ describe('sqlite backend specifics', () => { await reopened.close() }) + it('rejects setGlobal on a unit without a global slot and writes to undeclared tables', async () => { + const backend = backendAt(':memory:') + const unit = await backend.kv.open({ ...DESCRIPTOR, hasGlobal: false }) + await expect(unit.setGlobal({ g: 1 })).rejects.toThrow(/declared no global slot/) + await expect(unit.putRecord('undeclared', 'k', 1)).rejects.toThrow(/declared no table/) + expect((await unit.loadAll()).global).toBeNull() + await backend.close() + }) + + it('drains a still-pending failed open during close', async () => { + const path = await freshDbPath() + const first = backendAt(path) + await (await first.kv.open(DESCRIPTOR)).close() + await first.close() + + const backend = backendAt(path) + // Do not await: close() must tolerate an in-flight open that will reject + // (version mismatch) while its name is still reserved in the unit table. + const pending = backend.kv.open({ ...DESCRIPTOR, version: 99 }) + const closed = backend.close() + await expect(pending).rejects.toMatchObject({ code: 'version-mismatch' }) + await closed + }) + + it('propagates filesystem errors other than an existing database file', async () => { + if (process.platform === 'win32') return + const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-')) + dirs.push(dir) + await chmod(dir, 0o500) + const backend = backendAt(join(dir, 'storage.db')) + await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'EACCES' }) + await backend.close() + await chmod(dir, 0o700) + }) + + it('preserves the mode of an existing database file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + await writeFile(path, '', { mode: 0o644 }) + await chmod(path, 0o644) + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'k', 1) + await backend.close() + }) + + it('registers on the storage hub as backend sqlite and closes on dispose', async () => { + const ctx = new Context() + await ctx.plugin(Storage) + const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' }) + const backend = ctx.storage.backend.get('sqlite') + const unit = await backend.kv!.open(DESCRIPTOR) + await unit.putRecord('records', 'k', { n: 1 }) + + await fiber.dispose() + expect(ctx.storage.backend.names()).toEqual([]) + await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' }) + }) + it('rejects an unparsable global slot with malformed-medium', async () => { const path = await freshDbPath() const backend = backendAt(path) diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index e15c35b368..923cc4017a 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -16,3 +16,24 @@ Storage hub (`ctx.storage`) for non-session data: a named backend registry plus | `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite | | `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row | | `dsh-domain` | Domain data form (`ctx.storage.domain`): typed schemas, write chain, change events | + +## Model Experience + +### Backend and form registrations + +#### What the model sees + +Nothing. `ctx.storage` is a host-side registration table; the hub registers no tools, injects no prompts, and writes no session events. + +#### Token effect + +Zero direct tokens on every request. + +#### KV Cache effect + +Independent of live requests: the hub never touches a request prefix, so it cannot invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **`kv` is the only data shape** — the append-log facet the future session-backend migration needs is reserved in the design note but not yet defined; backends currently have exactly one facet to implement. +- **Forms resolve lazily** — reading `ctx.storage.domain` before the domain plugin mounts throws `form-not-mounted`; assemblies order plugins accordingly (misconfiguration fails loud rather than silently deferring). diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts index cede1284e3..a0b11503c7 100644 --- a/packages/storage/storage/tests/registry.spec.ts +++ b/packages/storage/storage/tests/registry.spec.ts @@ -33,11 +33,33 @@ describe('Storage service', () => { const facility = { marker: true } const dispose = ctx.storage.mount('domain' as never, facility as never) expect(ctx.storage.form('domain' as never)).toBe(facility) + expect(ctx.storage.domain).toBe(facility) expect(() => ctx.storage.mount('domain' as never, facility as never)).toThrowMatchingObject({ code: 'duplicate-mount', }) dispose() expect(() => ctx.storage.form('domain' as never)).toThrowMatchingObject({ code: 'form-not-mounted' }) + expect(() => ctx.storage.domain).toThrowMatchingObject({ code: 'form-not-mounted' }) + }) + + it('ignores a stale disposer after dispose and re-mount / re-register', async () => { + const ctx = new Context() + await ctx.plugin(Storage) + const first = { first: true } + const second = { second: true } + const staleMount = ctx.storage.mount('domain' as never, first as never) + staleMount() + ctx.storage.mount('domain' as never, second as never) + staleMount() + expect(ctx.storage.form('domain' as never)).toBe(second) + + const backendA = fakeBackend() + const backendB = fakeBackend() + const staleRegister = ctx.storage.backend.register('json', backendA) + staleRegister() + ctx.storage.backend.register('json', backendB) + staleRegister() + expect(ctx.storage.backend.get('json')).toBe(backendB) }) }) diff --git a/packages/workspace/README.md b/packages/workspace/README.md new file mode 100644 index 0000000000..4658080be9 --- /dev/null +++ b/packages/workspace/README.md @@ -0,0 +1,9 @@ +# workspace/ — the workspace entity + +The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). + +| Package | Role | ctx key | +|---|---|---| +| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` | + +Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives. diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index cdd4464cb0..a32528517f 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -16,7 +16,19 @@ Session persistence is an optional peer resolved with `ctx.get`: absent, attach ## Model Experience -No model-visible surface: the package registers no tools, injects no prompts, and emits no context. Token and KV-cache cost are zero. +### Workspace records and session accounts + +#### What the model sees + +Nothing. `ctx.workspace` serves workspace records to host-side consumers only: the package registers no tools, injects no prompts, and writes no session events, so no request field ever carries this package's data. + +#### Token effect + +Zero direct tokens on every request. + +#### KV Cache effect + +Independent of live requests: the package never touches a request prefix, so it cannot invalidate provider cache reuse. ## Known Limitations and Deferred Work diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts new file mode 100644 index 0000000000..552e8b6562 --- /dev/null +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import type { DomainChanged } from '@deepseek-ai/dsh-domain' +import * as WorkspaceInvariant from '../src/invariant.ts' +import { WorkspaceId } from '../src/index.ts' + +/** Boot the invariant service plus the companion over a stubbed registry knowing exactly `ids`. */ +async function setup(ids: string[]): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + ctx.provide('workspace', { + get: (id: WorkspaceId) => (ids.includes(id) ? { id } : undefined), + }) + await ctx.plugin(WorkspaceInvariant) + return ctx +} + +type ChangeLocation = Partial> + +const put = (overrides?: ChangeLocation): DomainChanged => ({ + domain: 'workspace', + table: 'workspaces', + key: 'w1', + operation: 'put', + value: {}, + ...overrides, +}) + +const deleted = (): DomainChanged => ({ + domain: 'workspace', + table: 'workspaces', + key: 'w1', + operation: 'deleted', +}) + +describe('workspace cache/table invariant', () => { + it('accepts a put whose record has a cached entity and ignores foreign events', async () => { + const ctx = await setup(['w1']) + expect(() => { ctx.emit('domain/changed', put()) }).not.toThrow() + // Other domains and other tables are out of scope, whatever their shape. + expect(() => { ctx.emit('domain/changed', put({ domain: 'other', key: 'missing' })) }).not.toThrow() + expect(() => { ctx.emit('domain/changed', put({ table: 'other', key: 'missing' })) }).not.toThrow() + }) + + it('fails a deleted operation — this phase exposes no delete entry point', async () => { + const ctx = await setup(['w1']) + expect(() => { ctx.emit('domain/changed', deleted()) }) + .toThrow(/no delete entry point/) + }) + + it('fails a put whose record the registry cache does not hold', async () => { + const ctx = await setup([]) + expect(() => { ctx.emit('domain/changed', put()) }).toThrow(/diverged/) + }) +}) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 4ff1706b7b..360103f7a0 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { Context } from 'cordis' import Storage from '@deepseek-ai/dsh-storage' +import type { StorageBackend } from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-domain' import type { DomainChanged } from '@deepseek-ai/dsh-domain' import { SessionId } from '@deepseek-ai/dsh-session' @@ -24,10 +25,11 @@ const header = (id: string, cwd?: string): SessionHeader => async function harness(options?: { pool?: MemoryMediaPool sessions?: SessionHeader[] | 'absent' + backend?: StorageBackend }) { const ctx = new Context() await ctx.plugin(Storage) - ctx.storage.backend.register('memory', new MemoryStorageBackend(options?.pool)) + ctx.storage.backend.register('memory', options?.backend ?? new MemoryStorageBackend(options?.pool)) ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} })) let listed = options?.sessions === 'absent' ? undefined : options?.sessions ?? [] if (listed !== undefined) { @@ -44,6 +46,36 @@ async function harness(options?: { } } +/** A memory backend whose next `putRecord` throws once when armed, for write-failure paths. */ +function failingBackend(): { backend: StorageBackend; arm: () => void } { + const inner = new MemoryStorageBackend() + let failNext = false + return { + arm: () => { failNext = true }, + backend: { + kv: { + open: async (descriptor) => { + const unit = await inner.kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: async (table, key, value) => { + if (failNext) { + failNext = false + throw new Error('medium write failed (injected)') + } + return unit.putRecord(table, key, value) + }, + deleteRecord: (table, key) => unit.deleteRecord(table, key), + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + } + }, + }, + close: () => inner.close(), + }, + } +} + /** A pool pre-stamped with one stored workspace record, simulating a prior run. */ function pooledRecord(id: string, record: WorkspaceRecord): MemoryMediaPool { const pool = new MemoryMediaPool() @@ -134,6 +166,25 @@ describe('WorkspaceRegistry.create', () => { expect(await registry.resolveByPath(link)).toBe(workspace) expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined() }) + + it('rolls the entity cache back when the durable write fails, leaving the path free to retry', async () => { + const dir = await makeDir('rollback') + const { backend, arm } = failingBackend() + const { registry } = await harness({ backend }) + arm() + await expect(registry.create(dir)).rejects.toThrow(/injected/) + expect(registry.list()).toEqual([]) + const retried = await registry.create(dir) + expect(retried.path).toBe(dir) + }) + + it('rejects any table access before the registry has started', async () => { + const dir = await makeDir('unstarted') + const ctx = new Context() + // Constructed directly, Service.init never ran: no domain, no table. + const registry = new WorkspaceRegistry(ctx) + await expect(registry.create(dir)).rejects.toThrow(/not started/) + }) }) describe('Workspace.attachSession', () => { @@ -235,7 +286,24 @@ describe('consistency projections', () => { const id = WorkspaceId('00000000-0000-4000-8000-000000000002') const pool = pooledRecord(id, record(dir, ['maybe'])) const { registry } = await harness({ pool, sessions: 'absent' }) - expect(registry.get(id)!.sessionIds).toEqual(['maybe']) + const workspace = registry.get(id)! + expect(workspace.sessionIds).toEqual(['maybe']) + // Mutations must not prune either: unverifiable membership is kept as-is. + await workspace.setTitle('still-unverified') + expect(storedRecord(pool, id).sessionIds).toEqual(['maybe']) + }) + + it('prunes dead ids even when the triggering mutation is itself a no-op', async () => { + const dir = await makeDir('prune-on-noop') + const id = WorkspaceId('00000000-0000-4000-8000-000000000007') + const pool = pooledRecord(id, record(dir, ['ghost'])) + const { registry, changes } = await harness({ pool, sessions: [] }) + const workspace = registry.get(id)! + // Detaching an id that was never on the account changes nothing by + // itself, but the mutation slot still prunes the dead 'ghost' durably. + await workspace.detachSession(SessionId('never-there')) + expect(storedRecord(pool, id).sessionIds).toEqual([]) + expect(changes).toHaveLength(1) }) it('rejects startup over a medium accounting one session twice', async () => { @@ -264,6 +332,20 @@ describe('consistency projections', () => { }) }) +describe('Workspace mutation failures', () => { + it('propagates a medium write failure from a mutation and keeps the old snapshot', async () => { + const dir = await makeDir('write-fail') + const { backend, arm } = failingBackend() + const { registry } = await harness({ backend }) + const workspace = await registry.create(dir) + arm() + await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/) + expect(workspace.title).toBe('write-fail') + await workspace.setTitle('kept') + expect(workspace.title).toBe('kept') + }) +}) + describe('Workspace.status', () => { it('reports ok while the directory exists and missing-dir once it is gone, without mutating the record', async () => { const dir = await makeDir('vanishing') @@ -274,5 +356,8 @@ describe('Workspace.status', () => { expect(await workspace.status()).toBe('missing-dir') expect(workspace.path).toBe(dir) expect(registry.get(workspace.id)).toBe(workspace) + // The path re-materializing as a non-directory is still missing-dir. + await writeFile(dir, 'now a file') + expect(await workspace.status()).toBe('missing-dir') }) }) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 7e4d154174..45c7e74414 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1020, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, - "packages/README.md": 760 + "packages/README.md": 790 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3274a83c3..080a13ef2e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -207,6 +207,11 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', + DomainChanged: 'event-local snapshot is owned by packages/storage/domain/src/events.ts', + DomainFacility: 'domain form facility is owned by packages/storage/domain/README.md', + DomainSpec: 'domain declaration contract is owned by packages/storage/domain/README.md', + StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts', + StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts', @@ -224,6 +229,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', + Workspace: 'workspace entity contract is owned by packages/workspace/workspace/README.md', + WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md', } /** Collect named references from parameter, generic-constraint/default, and return types. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1f54f7358f..2a32429867 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -77,6 +77,8 @@ const GROUP_ORDER = [ 'session-persistence', 'session-query', 'session-title', + 'storage', + 'workspace', 'support', 'ui', ] @@ -132,6 +134,23 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'storage', + pkg: 'storage', + title: 'Non-session storage hub', + mode: 'seam', + implementations: ['storage-json', 'storage-sqlite'], + consumers: ['domain', 'workspace'], + note: 'Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives.', + }, + { + key: 'workspace', + pkg: 'workspace', + title: 'Workspace entity registry', + mode: 'core', + consumers: [], + note: 'Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase.', + }, { key: 'sessionQuery', pkg: 'session-query',