feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -7,6 +7,6 @@ The storage family persists everything that is not a session event log: a hub wh
| `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` |
| `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` |
| `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` |
| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | mounts `ctx.storage.domain` |
| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | `ctx.storageDomain` + `ctx.storage.domain` |
Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Consumers never touch backends directly — they open declared domains through the domain form.
Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Each backend plugin publishes an internal lifecycle service after registration; the domain plugin injects every configured backend key before exposing its own service, so config-tree row order carries no startup semantics. Consumers never touch backends directly — they inject `storageDomain` and open declared domains through it.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-storage-domain
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility.
Domain data form for the DeepSeek Harness storage hub: exposes the injectable `ctx.storageDomain` service and the matching `ctx.storage.domain` projection after every configured backend is registered. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility.
Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
@@ -17,7 +17,7 @@ Design rationale, open semantics, and the storage/domain layer split live in the
#### What the model sees
Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storage.domain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface.
Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storageDomain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface.
#### Token effect

View File

@@ -9,6 +9,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import { DomainError } from './error.ts'
import { descriptorOf } from './spec.ts'
import type { DomainSpec } from './spec.ts'
@@ -31,6 +32,12 @@ declare module '@deepseek-ai/dsh-storage' {
}
}
declare module 'cordis' {
interface Context {
storageDomain: DomainFacility
}
}
/** Cordis plugin name. */
export const name = 'storage-domain'
/** The storage hub must be present before the form can mount. */
@@ -188,16 +195,26 @@ function parseRecord<T>(domain: string, table: string, key: string, parse: () =>
* Mount the domain data form on the storage hub.
* @param ctx - Plugin context.
* @param config - Validated plugin config.
* @returns resolution after an already-available backend set activates the form.
*/
export function apply(ctx: Context, config: Config) {
const facility = new DomainFacility(ctx, config)
ctx.effect(() => {
const unmount = ctx.storage.mount('domain', facility)
return async () => {
// Close leftovers before unmounting: draining writes still emit
// domain/changed, whose invariant resolves the facility through the hub.
await facility.closeAll()
unmount()
}
export function apply(ctx: Context, config: Config): Promise<void> {
const backendServices = [...new Set([
config.backend,
...Object.values(config.routes ?? {}),
])].map(storageBackendServiceKey)
const fiber = ctx.inject(backendServices, (domainCtx) => {
const facility = new DomainFacility(domainCtx, config)
domainCtx.effect(() => {
const unmount = domainCtx.storage.mount('domain', facility)
return async () => {
// Close leftovers before unmounting: draining writes still emit
// domain/changed, whose invariant resolves the facility through the hub.
await facility.closeAll()
unmount()
}
})
domainCtx.provide('storageDomain', facility)
})
return Promise.resolve(fiber).then(() => {})
}

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import Storage from '@deepseek-ai/dsh-storage'
import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
import type { Config } from '../src/index.ts'
import type { DomainChanged } from '../src/events.ts'
@@ -151,15 +151,26 @@ describe('DomainFacility.open', () => {
})
describe('plugin apply', () => {
it('mounts the facility as ctx.storage.domain through the plugin effect', async () => {
it('waits for routed backends, then mounts one lifecycle-bound service and form', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const DomainPlugin = await import('../src/index.ts')
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
expect(ctx.storage.domain).toBeInstanceOf(DomainFacility)
await fiber.dispose()
expect(ctx.get('storageDomain')).toBeUndefined()
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
const backend = new MemoryStorageBackend()
ctx.storage.backend.register('memory', backend)
const disposeBackend = ctx.provide(storageBackendServiceKey('memory'), backend)
await vi.waitFor(() => { expect(ctx.storageDomain).toBeInstanceOf(DomainFacility) })
expect(ctx.storage.domain).toBe(ctx.storageDomain)
disposeBackend()
await vi.waitFor(() => {
expect(ctx.get('storageDomain')).toBeUndefined()
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
})
await fiber.dispose()
})
})
@@ -295,10 +306,12 @@ describe('close and lifecycle', () => {
it('facility unmount closes domains the consumer never closed', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const backend = new MemoryStorageBackend()
ctx.storage.backend.register('memory', backend)
ctx.provide(storageBackendServiceKey('memory'), backend)
const DomainPlugin = await import('../src/index.ts')
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
const domain = await ctx.storage.domain.open(bareSpec)
const domain = await ctx.storageDomain.open(bareSpec)
const table = domain.table('rows')
await table.put('a', { label: 'x', count: 1 })
await fiber.dispose()

View File

@@ -9,7 +9,7 @@ import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
import { openJsonUnit } from './unit.ts'
@@ -110,4 +110,5 @@ export function apply(ctx: Context, config: Config) {
await backend.close()
}
})
ctx.provide(storageBackendServiceKey('json'), backend)
}

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Storage from '@deepseek-ai/dsh-storage'
import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { runKvBackendContract } from '../../storage/tests/contract.ts'
import { Config, JsonStorageBackend, apply } from '../src/index.ts'
@@ -185,10 +185,12 @@ describe('json backend specifics', () => {
await ctx.plugin(Storage)
const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root })
const backend = ctx.storage.backend.get('json')
expect(ctx.get(storageBackendServiceKey('json'))).toBe(backend)
const unit = await backend.kv!.open(descriptor)
await unit.putRecord('t', 'k', { v: 1 })
await fiber.dispose()
expect(() => ctx.storage.backend.get('json')).toThrow()
expect(ctx.get(storageBackendServiceKey('json'))).toBeUndefined()
await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' })
})

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type { DatabaseSync } from 'node:sqlite'
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
import { openDatabase, recordTableName, type JournalMode } from './schema.ts'
import { SqliteKvUnit } from './unit.ts'
@@ -164,4 +164,5 @@ export function apply(ctx: Context, config: Config) {
await backend.close()
}
}, 'storage-sqlite.registerBackend')
ctx.provide(storageBackendServiceKey('sqlite'), backend)
}

View File

@@ -4,7 +4,7 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import Storage from '@deepseek-ai/dsh-storage'
import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
import { runKvBackendContract } from '../../storage/tests/contract.ts'
import * as StorageSqlite from '../src/index.ts'
@@ -233,11 +233,13 @@ describe('sqlite backend specifics', () => {
await ctx.plugin(Storage)
const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' })
const backend = ctx.storage.backend.get('sqlite')
expect(ctx.get(storageBackendServiceKey('sqlite'))).toBe(backend)
const unit = await backend.kv!.open(DESCRIPTOR)
await unit.putRecord('records', 'k', { n: 1 })
await fiber.dispose()
expect(ctx.storage.backend.names()).toEqual([])
expect(ctx.get(storageBackendServiceKey('sqlite'))).toBeUndefined()
await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
})

View File

@@ -15,6 +15,18 @@ export type { StorageErrorCode } from './error.ts'
export { UNIT_NAME_RE } from './backend.ts'
export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts'
/**
* Derive the Cordis lifecycle service that one named backend plugin provides.
* Domain-form providers inject these keys so activation cannot race backend
* registration even though callers continue resolving backends through the
* storage registry.
* @param name - Backend registry name.
* @returns the corresponding lifecycle-only service key.
*/
export function storageBackendServiceKey(name: string): string {
return `storage.backend.${name}`
}
declare module 'cordis' {
interface Context {
storage: Storage

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Storage, { BackendRegistry } from '../src/index.ts'
import Storage, { BackendRegistry, storageBackendServiceKey } from '../src/index.ts'
import type { StorageBackend } from '../src/index.ts'
const fakeBackend = (): StorageBackend => ({ close: async () => {} })
@@ -25,6 +25,11 @@ describe('BackendRegistry', () => {
})
describe('Storage service', () => {
it('derives stable lifecycle service keys for named backends', () => {
expect(storageBackendServiceKey('json')).toBe('storage.backend.json')
expect(storageBackendServiceKey('tenant-a')).toBe('storage.backend.tenant-a')
})
it('mounts on the context and exposes registry plus form mounting', async () => {
const ctx = new Context()
await ctx.plugin(Storage)