Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md # .agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml # .agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/session.i18n.yaml # docs/core-data-structures/tools.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # packages/context/session-reference/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/hooks/hooks-claude/tests/coverage-cases.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/session-persistence/session-persistence/src/coordinator.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/normalize.ts # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/codec.ts # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/codec.spec.ts # packages/ui/acp/tests/config-options.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/stream-update.spec.ts # packages/ui/acp/tests/turns.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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-13-stream-workflow-progress-through-tool-calls.md: 668d38b38cab90f6ee9b613d1101d4d44302aa8a
|
||||
2026-07-13-stream-workflow-progress-through-tool-calls.zh.md: b0daba0c62c9b06f33f5e3c6319f90670941e762
|
||||
2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f
|
||||
2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The host's only persistence surface is the session event log (`packages/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today:
|
||||
|
||||
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
|
||||
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
|
||||
|
||||
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
|
||||
|
||||
## Proposal
|
||||
|
||||
Create the `packages/storage/` group — the `ctx.storage` hub (backend registry + data-form mounts), two backends, the domain data form — plus the workspace consumer package; extend `SessionPersistence` with a delete primitive.
|
||||
|
||||
| Package | Path | ctx surface | This phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage` (the hub) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | registers backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | registers backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | mounts `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` extension + cascade orchestration | `packages/session-persistence/*` | new method on the existing seam | ✗ future work (session side untouched this phase) |
|
||||
| `workspace.*` / `session.delete` RPC, GUI wiring, boot assembly | — | — | ✗ next phase |
|
||||
|
||||
(workspace lives in its own group rather than `packages/host/`: the host group's naming rule requires the `dsh-host-*` prefix while this package is named `dsh-workspace`; and the workspace entity is a domain concept, not bound to the host assembly tier. Unrelated to the existing `workspace-context` package — that is an AGENTS.md instruction loader.)
|
||||
|
||||
Dependency direction: `dsh-workspace` → `dsh-domain` → `dsh-storage` ← the two backends. `dsh-workspace` additionally depends on the read-only face of `ctx.sessionPersistence` (attach's cwd check reads the session header; when the service is absent, attach rejects outright — no verification, no bookkeeping). The `ctx.sessions` running-check for session deletion moves into future work together with the cascade.
|
||||
|
||||
### `dsh-storage`: the storage hub
|
||||
|
||||
A pure registration hub, no IO of its own, no Config. The `Storage` service mounts at `ctx.storage` with two faces: `backend` (a `BackendRegistry`: `register(name, backend)` returns the disposer, duplicate names throw; `get(name)` throws `backend-not-found` for unknown names) and data-form mounting (`mount(form, facility)` over the merge-extensible `StorageForms` map, into which `dsh-domain` merges the `domain` key; unmounted access throws `form-not-mounted`). The signature text lives in `packages/storage/storage/src/index.ts` and `src/registry.ts`.
|
||||
|
||||
**Multiple backends stay mounted side by side**; which backend serves a domain is `dsh-domain`'s configuration (below), never a global either-or. Disposer semantics = remove the name from the table; closing the backend itself belongs to the backend package's effect closure, unregister first then close.
|
||||
|
||||
A backend is one **medium owner** (a file-tree root / one db file) exposing primitives through **data-shape facets** — only `kv` this phase; the session migration adds `log` (see the migration section). A facet is an optional member: absence means the backend cannot serve that shape, and resolution fails loud. The `kv` facet's primitive surface: `open(descriptor)` (descriptor = name/version/table list/global flag, with names and table names restricted to `^[a-z][a-z0-9_]*$` doubling as file-name and SQL-identifier segments) returns a unit exposing `loadAll` / `putRecord` / `deleteRecord` (missing key is a no-op) / `setGlobal` / `close` (idempotent); values are opaque JSON to the backend. The normative text (with per-method JSDoc) is `packages/storage/storage/src/backend.ts`.
|
||||
|
||||
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
|
||||
|
||||
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
|
||||
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
|
||||
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
|
||||
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
|
||||
5. `deleteRecord` is idempotent; `putRecord` overwrites.
|
||||
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
|
||||
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
|
||||
|
||||
The error vocabulary is `StorageError` with a code discriminant: `backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed` (`packages/storage/storage/src/error.ts`).
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config is `root` only (required, no default, schemastery); apply registers backend `json` inside `ctx.effect()`, and the disposer unregisters the name before `backend.close()`.
|
||||
|
||||
- Layout `<root>/<unitName>.json`, one file per unit; directory 0o700, files 0o600.
|
||||
- File format (version stamp in the header; the file is always the current net state, `JSON.stringify(…, null, 2)` human-readable — that legibility is this backend's reason to exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- Writes: every write primitive = full serialization of the in-memory state → temp write + fsync → atomic rename publish (the Windows variant follows session-persistence-jsonl's win32 path). Memory is authoritative, disk is its projection.
|
||||
- `loadAll`: parse the whole file at open; a missing `unit` header, non-object tables, etc. → `malformed-medium`. A missing file = an empty unit, materialized on first write.
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`.
|
||||
|
||||
- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables.
|
||||
- Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`.
|
||||
- DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
|
||||
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
|
||||
|
||||
### `dsh-domain`: the domain data form
|
||||
|
||||
A single implementation, not abstracted; consumers depend on this layer only and never touch backends directly.
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(Facility unmount order: dispose each domain first (drain its write chain), then remove the name from the hub — in-flight writes still emit `domain/changed` during the drain, and the event-consistency invariant resolves domains back through the facility, so the name must stay resolvable at that point.)
|
||||
|
||||
Domain declarations (the spec object is defined and exported by the package that owns the domain — the single source of type and runtime truth; schemas use zod with `z.infer` deriving the types without re-declaration — the record model projects into RPC wire schemas next phase and the wire boundary is all zod; schemastery still owns plugin Config only):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` exact semantics (sequential; any failing step fails the whole open):
|
||||
|
||||
1. A domain with this name already open → `DomainError('already-open')`.
|
||||
2. Backend name = `config.routes[spec.name] ?? config.backend`; `ctx.storage.backend.get(name)` (an unmounted name propagates `backend-not-found` — misconfiguration fails loud).
|
||||
3. Backend lacks the `kv` facet → `DomainError('facet-unsupported')`.
|
||||
4. `kv.open(descriptorOf(spec))` (the descriptor is a direct projection of the spec).
|
||||
5. `loadAll()`; every record passes `valueSchema.parse`, the global passes its schema (null takes `initial`, not persisted — first write materializes). A failure → `DomainError('invalid-record', { table, key })` (the durable boundary must validate; the write side does not re-validate).
|
||||
6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`.
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
|
||||
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
|
||||
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
|
||||
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
|
||||
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
|
||||
|
||||
### Future work: session-side deletion (design settled, not implemented this phase)
|
||||
|
||||
This section is the settled construction spec; the implementation phase changes code only, not semantics. No session-persistence file is modified this phase.
|
||||
|
||||
```ts ignore-check
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject.
|
||||
- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject.
|
||||
- After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable.
|
||||
|
||||
Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules):
|
||||
|
||||
| Check (in order) | On failure |
|
||||
| --- | --- |
|
||||
| No target (the whole subtree when recursive) is running in `ctx.sessions` | throw, delete nothing; callers cancel first then delete — the persistence layer never reaches back into the runtime |
|
||||
| Non-recursive: the target has no descendants (descendants = the `parentSessionId` transitive closure, derived from `list()` headers) | throw: by default only leaves are deletable; `recursive: true` opts into recursion |
|
||||
| Recursive order is bottom-up (leaves → root) | — a mid-way crash leaves only "half the subtree deleted, ancestors intact"; re-running the same delete converges, and no dangling parent exists at any moment |
|
||||
| Some id in the cascade is already gone from disk | skip (idempotent resumption); any other error aborts |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
The package owns the `WorkspaceId` brand and exposes `ctx.workspace`. The record key is a generated uuid — path is not the key: normalization rewrites it, and reference anchors must be stable.
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
}
|
||||
```
|
||||
|
||||
- **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side.
|
||||
- **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace.
|
||||
- Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas.
|
||||
- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record.
|
||||
|
||||
Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline):
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| A ledger id has no session on disk | filtered at `list()`/entity projection; pruned by the next mutate; no error (a normal product of deletion crash-consistency) |
|
||||
| A session's cwd matches a workspace but is not in the ledger | not owned: no merging, no adoption. The GUI may later build an "orphan sessions" area (orphans = the complement of all ledgers) |
|
||||
| One session in two ledgers | structurally blocked on the write side (attach check); detected at load → throw (externally hand-edited data, never masked) |
|
||||
| The workspace directory does not exist | record and ledger stay; `status()` = `'missing-dir'`; the storage layer never auto-deletes (the directory may only be temporarily moved) |
|
||||
|
||||
### Reuse and the session-backend migration outlook
|
||||
|
||||
**Long-term direction**: the pure medium operations inside session-persistence's JSONL/SQLite backends sink into `dsh-storage` backends (the session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file/db operation layer beneath them does). The motive for reuse: the medium layer is all filesystem operations, database calls, and cross-platform grit (Windows permission and atomic-publish variants, fsync semantics, exclusive file creation…), which should be written once; business semantics (how a session appends, when, and what) stay above — while "did this append complete correctly underneath" (durability/atomicity/platform correctness) is the lower layer's responsibility, and the responsibility boundary is the facet primitive contract. The backend interface is therefore designed as **medium owner + data-shape facets**: a session log is an append-only stream, a different shape from KV — forcing them into one set of primitives would deform both, so facets split them (`kv` this phase, `log` at migration) while sharing the medium and its lifecycle.
|
||||
|
||||
The current reuse audit (an account already legible before the migration):
|
||||
|
||||
| Existing session-persistence logic | Nature | Disposition |
|
||||
| --- | --- | --- |
|
||||
| JSONL: temp write + fsync + link/unlink atomic publish, 0o700/0o600 permissions, Windows variant (win32.ts) | pure medium | copied by `dsh-storage-json` this phase (whole-file atomic rewrite is the same protocol); becomes the shared implementation at migration |
|
||||
| JSONL: line-append, first-line header fast read, zstd per-frame compression | log shape | stays put; moves into the `log` facet at migration |
|
||||
| SQLite: openDatabase (mkdir/exclusive create/PRAGMA sequence/user_version check) | pure medium | copied by `dsh-storage-sqlite` this phase — the two openDatabase copies are already near line-identical and this group is the third user; copy now, extract at migration |
|
||||
| SQLite: events/sessions schema, same-transaction materialization | log shape | stays put; moves into the `log` facet at migration |
|
||||
| coordinator (per-id write chain, lazy materialization, crash repair, flush barrier) | session semantics | never sinks — event-log domain logic whose counterpart here is the domain layer's write chain; each owns its own |
|
||||
| encodeSegment (id-to-path escaping) | medium utility | unused on the domain side (keys never reach paths); sinks together with the `log` facet (one file per session) at migration |
|
||||
|
||||
**This phase does not touch session-persistence's medium code** (only the delete primitive is added); the table above is the migration-phase work list and the design evidence that the backend interface must accommodate the log shape.
|
||||
|
||||
### Test matrix
|
||||
|
||||
| Suite | Coverage | Backends |
|
||||
| --- | --- | --- |
|
||||
| backend contract (shared suite, written once, run on both) | the seven contract clauses + version rejection + close idempotence | json, sqlite (`:memory:` + temp dirs) |
|
||||
| registry/mount | duplicate registration, unmounted access, disposer removal | — |
|
||||
| domain layer | the six open steps, schema rejection, update serialization (concurrent interleaving stress), `domain/changed` per record, global initial-value lazy materialization, routing and `facet-unsupported` | either (json) |
|
||||
| workspace | create/uniqueness/realpath, attach checks (including rejection when sessionPersistence is absent), the four consistency-doctrine cases | mock domain or json |
|
||||
| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl, sqlite |
|
||||
|
||||
Snapshots: no model-visible or assembly surface this phase, none added; next phase's RPC wiring brings them with the `workspace.*` domain.
|
||||
|
||||
### Out-of-scope list
|
||||
|
||||
| Not doing | Trigger | Rework point | Groundwork |
|
||||
| --- | --- | --- | --- |
|
||||
| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with |
|
||||
| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already |
|
||||
| Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only |
|
||||
| Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process |
|
||||
| Data migration | model changes after the first tagged release | version-driven per-domain migration | versions are on the medium from day one |
|
||||
| Large-table performance | a thousand-record domain routed to json | point `routes` at sqlite, migrate the data by hand once | routing is configuration; consumers unchanged |
|
||||
| Multi-segment keys | a real two-segment consumer appears (per-workspace per-session dimension data) | key generics become tuples, SQLite composite primary keys, JSON nested levels | single-level tables are the one-segment special case; no arbitrary-depth nesting; no string-concatenated keys |
|
||||
| The scope dimension | a "one per workspace" domain appears and composite keys cannot express it | DomainSpec gains a scope declaration + a scope segment in file names (encodeSegment) | the name character set is already restricted; file names cannot collide |
|
||||
| Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — |
|
||||
| Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow |
|
||||
| Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — |
|
||||
| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Reusing session-persistence's coordinator/backends**: event-log semantics (append-only, turn crash repair, lazy materialization) do not match KV overwrite semantics; only the layering idea is borrowed (a coordination layer owns write ordering, backends implement minimal primitives).
|
||||
- **A workspace-specific storage package, seam extracted later**: the second consumer (the session sidecar) is already foreseeable; generalizing later means touching the interface twice.
|
||||
- **Merging domain and storage into one layer**: backends would be forced to touch schema validation, change events, and write serialization — domain concerns; split apart, storage backends implement only opaque primitives (the smallest replaceable surface) while the single domain implementation concentrates all domain logic (zod/events/serialization written once, not doubled per backend).
|
||||
- **JSON backend as jsonl append + tombstones + compaction**: temp+fsync+rename crash safety is equivalent to append; rewriting keeps the file the net current state, human-readable, with no folding/compaction/torn-line tolerance; at domain scale a full rewrite costs the same as appending a line.
|
||||
- **JSON one file per table**: under whole-file rewrites the file granularity does not affect write cost; merging per domain means fewer files and gives the global singleton a home.
|
||||
- **SQLite storing a whole domain as one blob row**: any single-record change rewrites the whole domain, forfeiting per-key precise updates — SQLite's only edge over JSON reduced to zero.
|
||||
- **SQLite generating typed columns from the schema**: a DDL generator is over-engineering; document-per-row suffices, revisit when real query needs appear.
|
||||
- **One sqlite db file per domain**: contrary to the repository's one-database-many-tables convention.
|
||||
- **A single whole-store backend choice (the session-persistence single-slot pattern)**: the initial design; changed to coexisting backends + configured routing because the hub will carry multiple data forms whose backend preferences (human-readable vs high-frequency point updates) are bound to diverge — a single slot forces the coarse "swap everything + hand-migrate data" move. The cost is one extra name lookup, backed by fail-loud.
|
||||
- **path as the workspace key**: normalization/symlink resolution rewrites the path; reference anchors must be stable.
|
||||
- **Ownership derived from cwd (or merged with the ledger)**: two sources of truth; cwd cannot express ordering; ownership is a workspace-side fact to begin with.
|
||||
- **Change events carrying the old value**: the repository's change-event convention is "new snapshot + operation discriminant" (the sole exception, fs's before/after, is a method return value rather than an event, because the old value is unrecoverable afterwards and has a diff consumer); consumers needing diffs hold their own previous snapshot.
|
||||
- **Delete auto-cancelling a running session**: the persistence/orchestration layer reaching back into the runtime dirties the layering; cancel already exists, callers compose it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine).
|
||||
- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work).
|
||||
- Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase).
|
||||
- No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The repository's first push-mode change event on a persistence surface** (session-persistence polls revisions): the shape has the `goal/changed` template, but "the storage layer emits events" is a new precedent, validated only when next phase's RPC consumes it.
|
||||
- **The JSON backend's whole-unit rewrite scale premise**: if the second consumer (the session sidecar) lands on the JSON backend at thousand-record scale before being routed to SQLite, the rewrite cost surfaces earlier than expected; the mitigation is exactly `routes` pointing at sqlite.
|
||||
- **The deletion orchestration's weak dependency on `ctx.sessions`**: a headless assembly without the runtime registry treats it as "no hot sessions", leaving a window (an external process running the session); multi-process is already out of scope, accepted.
|
||||
- **Facet generalization designed against the future `log` facet without implementing it this phase**: a "reserved shape does not fit" risk; mitigated by organizing both backends' medium code in the sinkable shape from the reuse audit, so when the `log` facet lands only the facet layer moves.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求:
|
||||
|
||||
- **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。
|
||||
- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。
|
||||
|
||||
另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。
|
||||
|
||||
## Proposal
|
||||
|
||||
新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面)、两个后端、domain 领域数据形式——及 workspace 消费者包;给 `SessionPersistence` 扩删除原语。
|
||||
|
||||
| 包 | 路径 | ctx 面 | 本期 |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage`(枢纽) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册 backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册 backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | 挂载 `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` 扩面 + 级联删编排 | `packages/session-persistence/*` | 既有 seam 新方法 | ✗ future work(本期不动 session 侧) |
|
||||
| `workspace.*` / `session.delete` RPC、GUI 接线、boot 组装 | — | — | ✗ 下期 |
|
||||
|
||||
(workspace 放独立组不放 `packages/host/`:host 组命名规则要求 `dsh-host-*` 前缀,而包名定为 `dsh-workspace`;且 workspace 实体是领域概念,不绑定 host 装配层。与既有 `workspace-context` 包无关——那是 AGENTS.md 指令加载器。)
|
||||
|
||||
依赖方向:`dsh-workspace` → `dsh-domain` → `dsh-storage` ← 两后端。`dsh-workspace` 另依赖 `ctx.sessionPersistence` 的只读面(attach 的 cwd 校验读 session header;服务缺席时 attach 直接拒绝——无法校验即不写账)。session 删除相关的 `ctx.sessions` 运行中检查随级联删一并归入 future work。
|
||||
|
||||
### `dsh-storage`:存储枢纽
|
||||
|
||||
纯注册枢纽,自身不做 IO,无 Config。`Storage` service 挂 `ctx.storage`,两个面:`backend`(`BackendRegistry`:`register(name, backend)` 返回 disposer、重名 throw;`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map,`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts` 与 `src/registry.ts`。
|
||||
|
||||
**多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置(见下),不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。
|
||||
|
||||
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud。`kv` facet 的原语面:`open(descriptor)`(descriptor = 名字/版本/表名清单/有无 global,名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unit,unit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op)/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文(含逐方法 JSDoc)在 `packages/storage/storage/src/backend.ts`。
|
||||
|
||||
backend 契约(共享契约测试逐条断言,两后端同套件):
|
||||
|
||||
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
|
||||
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
|
||||
3. 持久性:写原语 resolve 后进程崩溃再 open,`loadAll` 必须反映该写入。
|
||||
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;后端只保证单次调用原子(JSON 整文件替换 / SQLite 单语句)。
|
||||
5. `deleteRecord` 幂等;`putRecord` 覆写。
|
||||
6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。
|
||||
7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。
|
||||
|
||||
错误词汇是带 code 判别的 `StorageError`,码表:`backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed`(`packages/storage/storage/src/error.ts`)。
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config 仅 `root`(必填无默认,schemastery);apply 在 `ctx.effect()` 里注册后端 `json`,disposer 先摘名再 `backend.close()`。
|
||||
|
||||
- 布局 `<root>/<unitName>.json`,一 unit 一文件;目录 0o700、文件 0o600。
|
||||
- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- 写入:任何一次写原语 = 内存态全量序列化 → temp 写 + fsync → rename 原子发布(Windows 变体照抄 session-persistence-jsonl 的 win32 路径)。内存态是权威,盘是投影。
|
||||
- `loadAll`:open 时整文件 parse;缺 `unit` 头、tables 非对象等 → `malformed-medium`。文件不存在 = 空单元,首写才落盘。
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`);apply 同 json,注册后端 `sqlite`。
|
||||
|
||||
- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-sqlite:mkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。
|
||||
- 物理布局版本 `STORAGE_SQLITE_SCHEMA_VERSION = 1` 存 `PRAGMA user_version`:0 → 盖章;≠ → `version-mismatch`。
|
||||
- DDL(全 STRICT;表名由受限字符集拼接加 `u_` 前缀,杜绝外部输入进 DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
|
||||
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
|
||||
|
||||
### `dsh-domain`:领域数据形式
|
||||
|
||||
单实现不抽象;消费者只依赖这层,不直接触后端。
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。)
|
||||
|
||||
域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` 精确语义(顺序执行,任一步失败即整体失败):
|
||||
|
||||
1. 同名域已打开 → `DomainError('already-open')`。
|
||||
2. 后端名 = `config.routes[spec.name] ?? config.backend`;`ctx.storage.backend.get(name)`(未挂载穿透 `backend-not-found`——misconfiguration fails loud)。
|
||||
3. 后端无 `kv` facet → `DomainError('facet-unsupported')`。
|
||||
4. `kv.open(descriptorOf(spec))`(descriptor 由 spec 直接投影)。
|
||||
5. `loadAll()`;每条记录 `valueSchema.parse`,global 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。
|
||||
6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
|
||||
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
|
||||
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
|
||||
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
|
||||
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。
|
||||
|
||||
### Future work:session 侧删除(设计定案,本期不实施)
|
||||
|
||||
本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。
|
||||
|
||||
```ts ignore-check
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL 后端:unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。
|
||||
- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`;0 行命中且无 intent → reject。
|
||||
- 删除成功后 emit `'session-persistence/deleted'(id: SessionId)`(`@mode emit`;session-persistence 层事件面,与 `domain/changed` 无关)。派生数据(session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。
|
||||
|
||||
编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则):
|
||||
|
||||
| 检查(按序) | 不满足时 |
|
||||
| --- | --- |
|
||||
| 目标(递归时含整棵子树)无一在 `ctx.sessions` 运行 | throw,什么都不删;调用方先 cancel 再删,持久层不反向牵动运行时 |
|
||||
| 非递归时目标无后代(后代 = `parentSessionId` 传递闭包,由 `list()` header 求得) | throw:默认只能删叶子,`recursive: true` 显式递归 |
|
||||
| 递归序自底向上(叶→根) | ——中途崩溃只留"子树删一半、祖先在",重跑收敛,任何时刻无悬空 parent |
|
||||
| 级联中某 id 已不在盘上 | 跳过(幂等续删);其余错误中止 |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
包拥有 `WorkspaceId` brand,暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key:规范化会改写它,引用锚点必须稳定。
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
}
|
||||
```
|
||||
|
||||
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。
|
||||
- **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。
|
||||
- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。
|
||||
- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。
|
||||
|
||||
一致性口径(账 = 归属唯一依据;实现与测试基准):
|
||||
|
||||
| 情形 | 行为 |
|
||||
| --- | --- |
|
||||
| 账中 id 盘上无 session | `list()`/实体投影时过滤;下次任何 mutate 顺手摘除;不报错(删除崩溃一致性的正常产物) |
|
||||
| session cwd 匹配某 workspace 但未上账 | 不属于:不合并不收编。GUI 将来可做"游离 session"专区(游离 = 全部账的补集) |
|
||||
| 同一 session 上两本账 | 写侧结构性堵死(attach 校验);load 检出 → throw(外部手改数据,不掩盖) |
|
||||
| workspace 目录不存在 | 记录与账保留,`status()` = `'missing-dir'`;存储层不自动删(目录可能只是暂时挪走) |
|
||||
|
||||
### 复用与 session 后端迁移展望
|
||||
|
||||
**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是 append-only 流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。
|
||||
|
||||
现状复用审计(迁移前就能看清的账):
|
||||
|
||||
| session-persistence 现有逻辑 | 归属 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| JSONL:temp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体(win32.ts) | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 |
|
||||
| JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 |
|
||||
| SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 |
|
||||
| encodeSegment(id 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet(一 session 一文件)迁移时随之下沉 |
|
||||
|
||||
**本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。
|
||||
|
||||
### 测试矩阵
|
||||
|
||||
| 套件 | 覆盖 | 后端 |
|
||||
| --- | --- | --- |
|
||||
| backend 契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite(`:memory:` + 临时目录) |
|
||||
| registry/mount | 重复注册、未挂载访问、disposer 摘除 | — |
|
||||
| domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一(json) |
|
||||
| workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json |
|
||||
| session delete 契约(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite |
|
||||
|
||||
快照:本期无模型可见面与组装面,不新增;下期 RPC 接线时随 `workspace.*` 域补。
|
||||
|
||||
### 不做清单
|
||||
|
||||
| 不做 | 触发条件 | 返工点 | 预埋 |
|
||||
| --- | --- | --- | --- |
|
||||
| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 |
|
||||
| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 |
|
||||
| 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 |
|
||||
| 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` |
|
||||
| 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 |
|
||||
| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite,数据手工导一次 | 路由即配置,消费者零改动 |
|
||||
| 多段 key | 两段 key 消费者出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key |
|
||||
| scope 维度 | "每 workspace 一份"的域出现且复合 key 表达不动 | DomainSpec 加 scope + 文件名 scope 段(encodeSegment) | 名字字符集已收紧,文件名不冲突 |
|
||||
| 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — |
|
||||
| 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 |
|
||||
| session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — |
|
||||
| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **复用 session-persistence 的 coordinator/后端**:事件日志语义(append-only、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。
|
||||
- **workspace 专用存储包,后续再抽 seam**:第二个消费者(session sidecar)已可预见,届时泛化要再动一次接口。
|
||||
- **domain 与 storage 合为一层**:后端会被迫接触 schema 校验、变更事件、写串行等领域关切;拆开后 storage 后端只做不透明原语(可替换面最小),domain 单实现收敛全部领域逻辑(zod/事件/串行化只写一遍,不随后端翻倍)。
|
||||
- **整库单后端二选一(学 session-persistence 单坑位模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单坑位会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步,fail-loud 兜底。
|
||||
- **JSON 后端 jsonl 追加 + 墓碑 + 压实**:temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。
|
||||
- **JSON 一表一文件**:覆写下文件粒度不影响写成本,按域合并文件更少,global 单例有落点。
|
||||
- **SQLite 整域存单行 blob**:任何一条记录变更都重写整域,失去按 key 精确更新——SQLite 相对 JSON 的唯一优势归零。
|
||||
- **SQLite 按 schema 生成 typed columns**:DDL 生成器过度建设;document-per-row 足够,查询需求出现再议。
|
||||
- **每域独立 sqlite db 文件**:与仓库一库多表惯例相反。
|
||||
- **path 作为 workspace key**:规范化/符号链接解析会改写 path;引用锚点必须稳定。
|
||||
- **归属用 cwd 派生(或与账合并)**:双真相源;cwd 表达不了排序;归属本就是 workspace 侧事实。
|
||||
- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费者);需要 diff 的消费者自己持有上次快照。
|
||||
- **删除自动 cancel 运行中 session**:持久层/编排层反向牵动运行时,层次变脏;cancel 机制已存在,调用方组合即可。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。
|
||||
- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。
|
||||
- session-persistence 包零 diff(本期不动 session 侧的验收线)。
|
||||
- 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。
|
||||
|
||||
## Risks
|
||||
|
||||
- **仓库持久化面第一个推式变更事件**(session-persistence 靠 revision 轮询):形态虽有 `goal/changed` 范本,但"存储层发事件"是新先例,下期 RPC 消费时才能验证形态是否合适。
|
||||
- **JSON 后端整域覆写的规模前提**:若第二个消费者(session sidecar)在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。
|
||||
- **删除语义的编排层检查依赖 `ctx.sessions` 弱依赖**:headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session);多进程本就在不做清单内,接受。
|
||||
- **facet 泛化以未来的 `log` facet 为设计依据但本期不实现它**:存在"预留形状不合身"的风险;缓解是本期后端介质代码按复用审计表的下沉形状组织,`log` facet 真正落地时只动 facet 层。
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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-06-30-pre-tool-input-rewrite.md: 5de605e7edf63046b14a125d53072aab9bb5d37e
|
||||
2026-06-30-pre-tool-input-rewrite.zh.md: eab80764f5dfbd0448dabf0dc3c893ba2ec94928
|
||||
2026-06-30-pre-tool-input-rewrite.md: f35e6af465ce8cec5685911c43e12b8dd66f2e6a
|
||||
2026-06-30-pre-tool-input-rewrite.zh.md: c94c647bb6867199b72528bc84c58a08ae93e27e
|
||||
|
||||
@@ -14,7 +14,7 @@ In the loop, a tool call's arguments are committed to the log and read by live c
|
||||
|
||||
1. **`assistant/message`** is appended before tool dispatch — it is the model-history source `deriveMessages()` replays, so it carries the tool-call arguments the model itself emitted.
|
||||
2. **`tool/call`** is the durable AUDIT record, appended before `ctx.tools.execute()`.
|
||||
3. **Live presentation reads `tool/call.arguments`**: the ACP bridge remembers them and passes them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
|
||||
3. **Human-facing presentation reads `tool/call.arguments`**: UI renderers pass them to `presentResult`; `dsh-tool-bash` derives the card title, the rawInput, the cwd, and the terminal-vs-background treatment from them.
|
||||
|
||||
An execution-only rewrite would make the UI show one command while another ran and render the result against the wrong arguments. The registry prevents that failure mode today: it structured-clones and deep-freezes `arguments`, makes the execution identity properties non-writable, and exposes no test shim or listener path that can replace them. The rewrite design must preserve that protected-identity boundary rather than weaken it.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: proposed
|
||||
|
||||
1. **`assistant/message`** 在工具分发之前追加——它是 `deriveMessages()` 回放时的模型历史来源,因此携带模型自身输出的工具调用参数。
|
||||
2. **`tool/call`** 是持久化的审计记录,在 `ctx.tools.execute()` 之前追加。
|
||||
3. **展示层实时读取 `tool/call.arguments`**:ACP(Agent Client Protocol)桥接记住这些参数并传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台处理方式。
|
||||
3. **面向人类的展示读取 `tool/call.arguments`**:UI 渲染器将这些参数传给 `presentResult`;`dsh-tool-bash` 从中派生卡片标题、rawInput、cwd 以及终端/后台处理方式。
|
||||
|
||||
如果只做执行层面的重写,UI 会显示一条命令而实际运行的是另一条,并且结果会对着错误的参数渲染。注册表目前通过以下方式防止这种失败模式:对 `arguments` 做 structured-clone 并深度冻结,将执行身份属性设为不可写,且不暴露任何可替换它们的测试 shim 或监听路径。重写设计必须维护这一受保护的身份边界,而非削弱它。
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# Agent Note: Stream workflow progress through tool calls
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-13-stream-workflow-progress-through-tool-calls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The workflow engine intentionally emits balanced `workflow/*` observation events for run, phase, narration, and child-agent progress, but no production consumer presents them. Editors therefore show one pending workflow tool card until the final result even while the engine already reports which phase is active, what the script logged, and which children started or settled. The [dynamic-workflows decision](../../implemented/feature/2026-07-05-dynamic-workflows.md) explicitly reserves ACP progress UI for this event stream.
|
||||
|
||||
Making `dsh-acp` listen to workflow events directly would invert the capability boundary: the generic UI bridge would depend on an optional workflow package and special-case one tool name. The tool pipeline already owns the routing facts a live update needs—agent and call id—but exposes only pure pending/final presenters, so a long-running tool has no provider-neutral way to report transient UI state between them.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add a live progress channel to `dsh-tools`. The registry-owned `ToolExecution` gains `reportProgress(view): boolean`, where `view` is a detached provider-neutral generic progress snapshot containing an optional replacement title and UI-facing content blocks. Progress cannot change the call's args-derived card tag, kind, raw input, locations, terminal intent, or diff intent; it updates only the live title/content within the presentation chosen up front. While the execution is active, the method validates and snapshots the view, then dispatches a contained, agent-scoped `tools/progress` observation carrying the authoritative execution identity and snapshot. Once final-result processing begins it returns `false` and emits nothing, so a late asynchronous reporter cannot overwrite a terminal card. Observer exceptions are logged and cannot fail the tool.
|
||||
|
||||
`dsh-acp` consumes `tools/progress` generically. It resolves the execution's agent through its existing agent-to-session map and emits an in-progress `tool_call_update` for the same call id. Because reporting is available only inside the tool execution pipeline, the durable `tool/call` and its ACP `tool_call` always precede the first update; closing the reporter before `tools/result` ensures no progress update follows the completed/failed card. Progress is live UI state rather than model input or durable history: session replay continues to reconstruct the pending and final cards from `tool/call` and `tool/result` without replaying transient updates.
|
||||
|
||||
`dsh-tool-workflow` becomes the first producer. Each tool execution installs a compact event capture before calling `ctx.workflows.start()`, because a valid engine may emit progress synchronously inside `start()`. Until the call returns, the capture reduces observed events into candidate states keyed by `WorkflowRunInfo.id`; it then selects the returned `WorkflowRun.id`, discards other candidates, reports the accumulated snapshot, and routes later matching events directly. If `start()` throws, the capture is disposed and its candidates are dropped. This preserves engine swappability without adding observer correlation to `WorkflowStartRequest` or requiring progress to wait until `start()` returns.
|
||||
|
||||
The reducer consumes the existing start, phase, log, agent-start, agent-end, and end events, reporting a replacement snapshot with the current phase, latest log line, active child labels, and completed/failed/cancelled counts. It does not accumulate a narration transcript; settled children leave the active set and become counters. `workflow/end`, tool settlement, or plugin disposal removes the reducer entry and event capture. The six workflow events, their metadata, paired child lifecycle, run handle, cancellation channels, and observer containment remain unchanged; third-party observers can continue consuming them directly.
|
||||
|
||||
Update the tool execution/presentation docs, generated event and API catalogs, workflow package docs, and the workflow data-structure catalog. ACP integration coverage must exercise the real workflow tool and worker seam with a scripted model boundary; the primary ACP snapshot suite adds one workflow-progress scenario because this changes the editor-facing transcript.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Delete the workflow observation surface.** Rejected in [the collapse-workflow simplification](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md): the events and their balanced lifecycle are intentional, and the missing piece is a consumer.
|
||||
|
||||
**Teach ACP about workflows directly.** This could map `WorkflowRunInfo` to a session and card, but it would make the generic bridge depend on an optional capability and bypass the rule that tools own presentation intent. A tool-progress channel solves the same routing problem for every long-running tool.
|
||||
|
||||
**Persist every progress update as a session event.** That would make live narration replayable, but it would permanently enlarge logs with state whose authoritative durable outcome is already the tool call/result pair. If resumable workflow progress becomes a product requirement, it needs a workflow-journaling design rather than UI snapshots disguised as durable facts.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `ToolExecution.reportProgress()` is registry-owned, agent-scoped, snapshotting, observer-contained, and returns `false` without dispatch after terminal processing starts.
|
||||
- ACP routes progress to the correct call in the correct live session; concurrent workflows in different sessions cannot cross-talk, and no `tool_call_update` appears before its `tool_call` or after its terminal update.
|
||||
- Workflow progress shows the current phase, latest log line, active children, and outcome counts while preserving all existing `workflow/*` events and run semantics; a seam test engine that emits start, phase, log, child, and end events synchronously inside `start()` loses none of that reducer state.
|
||||
- Cancellation, worker death, tool failure, session close, and plugin disposal release reducer state; replay emits only the durable pending/final card pair.
|
||||
- Unit, workflow integration, ACP integration, snapshot, typecheck, coverage, doc-sync, module-graph, build, and hygiene gates pass.
|
||||
|
||||
## Risks
|
||||
|
||||
This adds a public live-progress method and event to the tool seam, so implementations must keep the active/terminal boundary exact and detach snapshots before observers see them. The pre-start capture can briefly observe unrelated workflow runs, so it holds only compact candidate state keyed by run id and drops every non-matching candidate as soon as `start()` returns. A workflow can emit many progress changes; the bounded reducer avoids transcript growth but still sends one UI update per meaningful event after correlation. If measured clients need coalescing, it must be a defaulted validated bridge configuration rather than a hardcoded throttle. Transient progress intentionally disappears on replay, so the final tool result remains the only durable workflow card content.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Agent Note: 通过工具调用流式传输工作流进度
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-13-stream-workflow-progress-through-tool-calls.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工作流引擎有意为 run、phase、narration 和子 agent(智能体)进度发出成对的 `workflow/*` observation 事件,但目前没有生产消费方呈现这些事件。因此,编辑器在最终结果返回之前只显示一张 pending 状态的工作流工具卡片,尽管引擎已经报告了当前活跃的 phase、脚本日志内容以及哪些子 agent 已启动或已结束。[动态工作流决策](../../implemented/feature/2026-07-05-dynamic-workflows.md)明确将 ACP(Agent Client Protocol)进度 UI 保留给这一事件流。
|
||||
|
||||
如果让 `dsh-acp` 直接监听工作流事件,就会反转能力边界:通用的 UI 桥接层将依赖一个可选的工作流包(package),并对一个工具名做特殊处理。工具流水线已经拥有实时更新所需的路由信息(agent 和 call id),但只暴露了纯粹的 pending/final 展示器,因此长时间运行的工具没有提供方无关的方式在二者之间报告瞬态 UI 状态。
|
||||
|
||||
## 提案
|
||||
|
||||
为 `dsh-tools` 添加一条实时进度通道。注册表所有的 `ToolExecution` 新增 `reportProgress(view): boolean`,其中 `view` 是一个独立的、提供方无关的通用进度快照,包含可选的替换标题和面向 UI 的内容块。进度不能更改调用的 args 派生卡片标签、kind、原始输入、locations、terminal intent 或 diff intent;它只更新在最初选定的展示方式内的实时标题/内容。当执行处于活跃状态时,该方法校验并快照 view,然后分发一个受限的、agent 作用域的 `tools/progress` observation,携带权威的执行标识与快照。一旦 final-result 处理开始,方法返回 `false` 且不再分发,因此迟到的异步报告者无法覆盖终态卡片。观察者异常会被记录日志,不会导致工具失败。
|
||||
|
||||
`dsh-acp` 以通用方式消费 `tools/progress`。它通过既有的 agent 到会话映射解析执行所属的 agent,并为同一 call id 发出 in-progress 的 `tool_call_update`。由于报告仅在工具执行流水线内可用,持久化的 `tool/call` 及其 ACP `tool_call` 始终先于第一条 update;在 `tools/result` 之前关闭报告者,确保进度更新不会出现在 completed/failed 卡片之后。进度是实时 UI 状态,而非模型输入或持久历史:会话回放继续从 `tool/call` 和 `tool/result` 重建 pending 与 final 卡片,无需重放瞬态更新。
|
||||
|
||||
`dsh-tool-workflow` 成为第一个生产者。每次工具执行在调用 `ctx.workflows.start()` 之前安装一个紧凑的事件捕获器,因为合法的引擎可能在 `start()` 内部同步发出进度。在调用返回之前,捕获器将观察到的事件按 `WorkflowRunInfo.id` 归约为候选状态;随后选取返回的 `WorkflowRun.id`,丢弃其他候选,报告累积的快照,并将后续匹配事件直接路由。如果 `start()` 抛出异常,捕获器被 dispose(资源释放),其候选状态被丢弃。这在不向 `WorkflowStartRequest` 添加观察者关联、也不要求进度等到 `start()` 返回的前提下,保持了引擎的可替换性。
|
||||
|
||||
归约器消费既有的 start、phase、log、agent-start、agent-end 和 end 事件,报告一个替换快照,包含当前 phase、最新日志行、活跃子 agent 标签以及 completed/failed/cancelled 计数。它不累积 narration transcript(文本记录);已结束的子 agent 离开活跃集合,变为计数器。`workflow/end`、工具结算或插件 dispose 移除归约器条目和事件捕获器。六种工作流事件及其元数据、成对的子 agent 生命周期、run handle、取消通道和观察者隔离保持不变;第三方观察者可继续直接消费这些事件。
|
||||
|
||||
更新工具执行/展示文档、生成的事件与 API 目录、工作流包文档以及工作流数据结构目录。ACP 集成覆盖率必须使用脚本化的模型边界测试真实的工作流工具和 worker seam;主 ACP 快照套件新增一个 workflow-progress 场景,因为这改变了面向编辑器的 transcript。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**删除工作流 observation 表面。** 在[折叠工作流简化提案](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md)中被否决:这些事件及其成对生命周期是有意设计的,缺少的是消费方。
|
||||
|
||||
**让 ACP 直接了解工作流。** 这可以将 `WorkflowRunInfo` 映射到会话和卡片,但会使通用桥接层依赖一个可选能力,并绕过「工具拥有展示意图」的规则。工具进度通道为每个长时间运行的工具解决了相同的路由问题。
|
||||
|
||||
**将每条进度更新持久化为会话事件。** 这会使实时 narration 可回放,但会用一种状态永久膨胀日志,而该状态的权威持久结果已经是工具调用/结果对。如果可恢复的工作流进度成为产品需求,需要一个工作流日志化设计,而非伪装成持久事实的 UI 快照。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `ToolExecution.reportProgress()` 由注册表所有、agent 作用域、快照化、观察者隔离,且在终态处理开始后返回 `false` 而不分发。
|
||||
- ACP 将进度路由到正确的实时会话中的正确调用;不同会话中的并发工作流不能串扰,且 `tool_call_update` 不会出现在其 `tool_call` 之前或终态更新之后。
|
||||
- 工作流进度显示当前 phase、最新日志行、活跃子 agent 和结果计数,同时保留所有既有 `workflow/*` 事件和 run 语义;一个在 `start()` 内部同步发出 start、phase、log、child 和 end 事件的 seam 测试引擎不会丢失任何归约器状态。
|
||||
- 取消、worker 死亡、工具失败、会话关闭和插件 dispose 释放归约器状态;回放仅发出持久的 pending/final 卡片对。
|
||||
- 单元测试、工作流集成测试、ACP 集成测试、快照、类型检查、覆盖率、doc-sync(文档同步门禁)、module-graph、构建和 hygiene 门禁全部通过。
|
||||
|
||||
## 风险
|
||||
|
||||
本提案向工具 seam 添加了一个公开的实时进度方法和事件,因此实现方必须精确维护 active/terminal 边界,并在观察者看到快照之前将其分离。pre-start 捕获器可能短暂观察到无关的工作流 run,因此它仅按 run id 持有紧凑的候选状态,并在 `start()` 返回后立即丢弃所有不匹配的候选。一个工作流可能发出大量进度变更;有界归约器避免了 transcript 增长,但在关联完成后仍会为每个有意义的事件发送一条 UI 更新。如果经测量的客户端需要合并更新,这必须是一个带默认值的、经过校验的桥接配置,而非硬编码的节流。瞬态进度在回放时有意消失,因此最终工具结果仍是唯一持久的工作流卡片内容。
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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-14-sdk-developer-projects.md: aa5cf64d7dd33dea229d74c2ae45a9244ee70e3c
|
||||
2026-07-14-sdk-developer-projects.zh.md: 8f7d1de5b16f38019c802f07eda701cee72deb4f
|
||||
2026-07-14-sdk-developer-projects.md: 65d2bf66232993222832eb0f2f4f56cfcf7afd16
|
||||
2026-07-14-sdk-developer-projects.zh.md: 8435a07d9b2545a8f41a1f96743c9c7e6d4daf3d
|
||||
|
||||
@@ -59,7 +59,7 @@ The table is the developer-visible support set for this phase. A `required` feat
|
||||
| `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file |
|
||||
| `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders |
|
||||
| `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets |
|
||||
| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `tui` can select it because those two feature options provide the injected user-interaction service |
|
||||
| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `tui` can select it because ACP is an automation transport and embed provides no human-interaction service |
|
||||
|
||||
Both `bash` feature options apply to ACP, TUI, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`:
|
||||
|
||||
@@ -107,7 +107,7 @@ Generated `package.json` provides the following scripts. `dev`, `build`, `start`
|
||||
`dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`.
|
||||
|
||||
- TUI projects pass the selected model through `--model=<name>` and create or resume an agent according to optional `--resume=<session-id>`;
|
||||
- ACP uses protocol `session/load`
|
||||
- ACP clients create fresh sessions through protocol `session/new`;
|
||||
- Embed uses the model written into the generated code.
|
||||
|
||||
Each feature-owned Cordis config entry keeps its developer-editable Cordis plugin config and explanatory comments in `cordis.yml`. When `dsh-sdk config` changes other features, it preserves unknown fields, formatting on untouched nodes, and comments. HMR is an ordinary leaf config entry: when the feature is selected, dev and start load the same watcher, and the command does not change the plugin tree implicitly.
|
||||
|
||||
@@ -59,7 +59,7 @@ create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `pl
|
||||
| `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 |
|
||||
| `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 |
|
||||
| `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 |
|
||||
| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/tui 两个功能选项提供,因此仅这两个接口可选 |
|
||||
| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;只有 `tui` 可选,因为 ACP 是自动化传输,而 embed 不提供人类交互服务 |
|
||||
|
||||
`bash` 的两个功能选项都适用于 ACP、TUI 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`:
|
||||
|
||||
@@ -107,7 +107,7 @@ my-agent/
|
||||
`dsh-sdk start` 与 `dsh-sdk dev` 可以接收模块 target,并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`,bare flag 转换为 `true`,`--no-*` 转换为 `false`。
|
||||
|
||||
- TUI 工程通过 `--model=<name>` 传入所选 model,并根据可选的 `--resume=<session-id>` 创建或恢复 agent;
|
||||
- acp 使用协议 `session/load`
|
||||
- ACP 客户端通过协议 `session/new` 创建全新会话;
|
||||
- embed 使用生成代码中的 model。
|
||||
|
||||
每个功能拥有的 Cordis 配置项在 `cordis.yml` 中保留自己的可编辑 Cordis 插件配置和说明注释;`dsh-sdk config` 修改其他功能时必须保留未知字段、未修改节点的格式和注释。HMR(热模块替换)是普通叶子配置项:选择该功能后,dev 和 start 加载同一个 watcher,命令不隐式改变插件树。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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-04-prune-dead-core-spine-surface.md: 432aaa848540fe7d1db3039399345a02afed5bd3
|
||||
2026-07-04-prune-dead-core-spine-surface.zh.md: 2c7cc4c0dc234afc107bdfc715cf717e5ad8c55c
|
||||
2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3
|
||||
2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e
|
||||
|
||||
@@ -17,7 +17,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime
|
||||
| `ReactLoopAgent` root export | Outside-package named imports are tests; production programs against `Agent` and creates/resumes through `ctx.agents`. | Return/interface-type `Agent` and make the concrete loop class package-internal; keep the deliberate synchronous config-only `AgentLoop.create()` path. |
|
||||
| `workflow-workerthread` protocol/runtime/session re-exports and named `WorkerWorkflowEngine` | Every package-name consumer uses the default engine; the workflow Agent Note already defines the worker wire protocol as private. | Keep the default plugin class/config contract; drop the duplicate named class export and keep protocol modules source-private. |
|
||||
| `code-runtime-worker` protocol/bootstrap re-exports | Outside-package production/e2e consumers use `WorkerCodeRuntime` and config, not `BootstrapPort`, `PatchableStream`, or worker message/boot types. | Keep the runtime class/config contract and make its wire/bootstrap vocabulary source-private. |
|
||||
| ACP translation/presenter root exports | `agentOptions`, `streamSessionEventUpdate`, `todosToPlan`, `ToolPresenter`, `nullToolPresenter`, and `TerminalRendering` have only same-file or ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make translation/presentation helpers source-private and test them in-package. |
|
||||
| ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. |
|
||||
| `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. |
|
||||
| `depthOf`, `SubagentDepthError`, `SENSITIVE_ENV_PATTERN`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. | Keep depth/environment/exit behavior but make the helpers and error/regex source-private; test through spawn and disposal. |
|
||||
| `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. |
|
||||
|
||||
@@ -17,7 +17,7 @@ Status: proposed
|
||||
| `ReactLoopAgent` 根导出 | 包外的命名导入都是测试;生产代码面向 `Agent` 编程,通过 `ctx.agents` 创建/恢复。 | 返回/接口类型为 `Agent`,将具体循环类改为包内部;保留有意设计的同步、仅配置的 `AgentLoop.create()` 路径。 |
|
||||
| `workflow-workerthread` 的 protocol/runtime/session 再导出与命名的 `WorkerWorkflowEngine` | 每个包名消费方都使用默认引擎;工作流 Agent Note 已将 worker 协议格式(wire format)定义为私有。 | 保留默认插件类/配置契约;移除重复的命名类导出,将协议模块保持为源码私有。 |
|
||||
| `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 |
|
||||
| ACP 的 translation/presenter 根导出 | `agentOptions`、`streamSessionEventUpdate`、`todosToPlan`、`ToolPresenter`、`nullToolPresenter` 和 `TerminalRendering` 只有同文件或 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 translation/presentation 辅助函数改为源码私有,在包内测试。 |
|
||||
| ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 |
|
||||
| `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 |
|
||||
| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 |
|
||||
| `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 |
|
||||
|
||||
Reference in New Issue
Block a user