chore(storage,workspace): gates — coverage, catalogs, bilingual note
- Per-file 100% coverage across the five new packages (invariant companion suites, failure-injection negatives, lifecycle and malformed-medium branches). - Canonical README Model Experience / Known Limitations sections; new storage/ and workspace/ group READMEs; packages/README.md rows (budget ceiling raised 760 → 790 for the two new groups). - Cordis catalog/type-link registrations, service-role classification, and regenerated catalogs/graphs for the new services and events. - Agent Note: English body + i18n pairing record; design-sketch fences opted out of doc-typecheck as ignore-check. - Two exactOptionalPropertyTypes/discriminant fixes in new tests. doc-sync (24 gates), typecheck, hygiene, and the five-package suite (92 tests) all pass.
This commit is contained in:
9
packages/workspace/README.md
Normal file
9
packages/workspace/README.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
56
packages/workspace/workspace/tests/invariant.spec.ts
Normal file
56
packages/workspace/workspace/tests/invariant.spec.ts
Normal file
@@ -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<Context> {
|
||||
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<Pick<DomainChanged, 'domain' | 'table' | 'key'>>
|
||||
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user