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:
@@ -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
|
||||
|
||||
|
||||
@@ -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