feat(web): add workspace-aware session flow
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user