feat(workspace): persistent workspace entity over the domain form

ctx.workspace registry owns WorkspaceId-branded records: realpath-
normalized unique paths (create rejects collisions; resolveByPath shares
the normalization), ordered sessionIds as the single source of ownership
truth, attachSession gated on the session header cwd matching the
workspace path (double-booking structurally impossible), dead session
ids filtered on projection and pruned on the next mutate, status()
reporting missing directories. No delete surface this phase — deletion
ships together with the session-side primitives as future work.
This commit is contained in:
imccyu
2026-07-24 19:07:27 +08:00
parent 7c27107be4
commit 013e6f8769
10 changed files with 915 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-workspace
Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records — a stable `WorkspaceId`, a canonical directory path, a display title, and the ordered account of owned sessions — stored through the domain data form (`workspaceDomainSpec`, table `workspaces`). Consumers see the `Workspace` interface only; the entity implementation stays package-private.
Design rationale, the path/uniqueness canon, and the consistency rules live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
## Shape
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath` (trailing slashes, `..`, symlinks), rejects a nonexistent directory (the original `ENOENT`) and a canonical path another workspace already owns. Title defaults to `basename(path)`.
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups; `resolveByPath` is async because it runs the same `realpath` canon first.
- `Workspace.attachSession(id)` — idempotent; validates that the session's stored header `cwd`, canonicalized the same way, equals the workspace path. A missing persistence service, unknown session, absent or unresolvable `cwd`, or mismatch rejects without writing (what cannot be validated is not recorded). `detachSession` removes from the account only, never touching the session's own log.
- `Workspace.sessionIds` — the ordered ownership account (array order is display order). Accounted ids whose session no longer exists are filtered from the projection and pruned durably on the next mutation; a medium accounting one session under two workspaces rejects at startup (external edit — the attach check makes it unwritable).
- `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record.
Session persistence is an optional peer resolved with `ctx.get`: absent, attach rejects and projections serve the account unfiltered.
## 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.
## Known Limitations and Deferred Work
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
- No RPC surface or GUI wiring yet; the record schema is the direct source of the next phase's wire projection.
- The known-session view refreshes at startup and on attach validation; a session deleted by an external process during this one is filtered only after the next refresh.

View File

@@ -0,0 +1,50 @@
{
"name": "@deepseek-ai/dsh-workspace",
"description": "Workspace entity registry (ctx.workspace): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-domain": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-storage": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-domain": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,150 @@
/**
* Package-private workspace entity: the single {@link Workspace}
* implementation. Holds a record snapshot that is swapped in place after each
* durable mutation; every write funnels through the private `mutate` so
* `updatedAt` stamping and dead-account pruning happen exactly once.
* Not re-exported from the package entrypoint — consumers see only the
* `Workspace` interface.
* @module @deepseek-ai/dsh-workspace/src/entity
*/
import { stat } from 'node:fs/promises'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { KvTable } from '@deepseek-ai/dsh-domain'
import type { WorkspaceRecord } from './spec.ts'
import type { Workspace, WorkspaceId } from './types.ts'
import { realpathNormalize } from './paths.ts'
/**
* The registry-owned machinery an entity mutates through. Entities never see
* the registry itself — only the open table, the known-session view backing
* the `sessionIds` projection, and header reads for attach validation.
*/
export interface WorkspaceEntityHost {
/**
* Resolve the open `workspaces` table.
* @returns the table; throws while the registry has not started yet.
*/
table(): KvTable<WorkspaceId, WorkspaceRecord>
/**
* Synchronous view of the session ids known to exist in session
* persistence.
* @returns the id set, or `undefined` when persistence has been absent so
* far (membership cannot be verified, so projections serve the account
* unfiltered).
*/
knownSessionIds(): ReadonlySet<string> | undefined
/**
* Read one stored session header for attach validation.
* @param id - The session whose header to read.
* @returns the header; rejects when session persistence is absent or holds
* no session with this id.
*/
readSessionHeader(id: SessionId): Promise<SessionHeader>
}
/** The single {@link Workspace} implementation; constructed only by the registry. */
export class WorkspaceEntity implements Workspace {
private record: WorkspaceRecord
/**
* @param host - Registry-owned table, known-session view, and header reads.
* @param id - The record's stable id.
* @param record - The validated record snapshot loaded or just written.
*/
constructor(
private readonly host: WorkspaceEntityHost,
readonly id: WorkspaceId,
record: WorkspaceRecord,
) {
this.record = record
}
get path(): string {
return this.record.path
}
get title(): string {
return this.record.title
}
get sessionIds(): readonly SessionId[] {
const known = this.host.knownSessionIds()
if (known === undefined) return this.record.sessionIds
return this.record.sessionIds.filter(id => known.has(id))
}
async setTitle(title: string): Promise<void> {
await this.mutate(record => ({ ...record, title }))
}
async attachSession(sessionId: SessionId): Promise<void> {
if (this.record.sessionIds.includes(sessionId)) return
const header = await this.host.readSessionHeader(sessionId)
if (header.cwd === undefined) {
throw new Error(
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
+ 'its stored header carries no cwd to validate against',
)
}
let cwd: string
try {
cwd = await realpathNormalize(header.cwd)
} catch (error) {
throw new Error(
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
+ `its cwd '${header.cwd}' does not resolve, so it cannot be validated`,
{ cause: error },
)
}
if (cwd !== this.record.path) {
throw new Error(
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
+ `its cwd resolves to '${cwd}'`,
)
}
await this.mutate(record => record.sessionIds.includes(sessionId)
? record
: { ...record, sessionIds: [...record.sessionIds, sessionId] })
}
async detachSession(sessionId: SessionId): Promise<void> {
if (!this.record.sessionIds.includes(sessionId)) return
await this.mutate(record => ({
...record,
sessionIds: record.sessionIds.filter(id => id !== sessionId),
}))
}
async status(): Promise<'ok' | 'missing-dir'> {
try {
return (await stat(this.record.path)).isDirectory() ? 'ok' : 'missing-dir'
} catch {
// Any stat failure (ENOENT, dangling parent, permission loss) means the
// directory is not usable right now; the record itself never mutates.
return 'missing-dir'
}
}
/**
* The single write path: run `fn` on the domain write chain via
* `table.update`, stamping `updatedAt` and pruning accounted ids whose
* session no longer exists (consistency rule: dead ids are dropped on the
* next mutation, whatever that mutation is), then swap the snapshot.
*/
private async mutate(fn: (record: WorkspaceRecord) => WorkspaceRecord): Promise<void> {
const known = this.host.knownSessionIds()
this.record = await this.host.table().update(this.id, (current) => {
const next = fn(current)
return {
...next,
sessionIds: known === undefined
? next.sessionIds
: next.sessionIds.filter(id => known.has(id)),
updatedAt: new Date().toISOString(),
}
})
}
}

View File

@@ -0,0 +1,213 @@
/**
* Workspace entity registry (`ctx.workspace`): durable workspace records over
* the domain data form, with session attachment validated against stored
* session headers. This package owns the `WorkspaceId` brand and the
* `workspace` domain; consumers see the {@link Workspace} interface only.
* @module @deepseek-ai/dsh-workspace
*/
import { randomUUID } from 'node:crypto'
import { basename } from 'node:path'
import { Context, Service } from 'cordis'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
// Type-only: merges `sessionPersistence` into the Context service map for the
// optional `ctx.get` lookups below.
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { KvTable } from '@deepseek-ai/dsh-domain'
import { workspaceDomainSpec } from './spec.ts'
import type { WorkspaceRecord } from './spec.ts'
import { WorkspaceEntity } from './entity.ts'
import type { WorkspaceEntityHost } from './entity.ts'
import { realpathNormalize } from './paths.ts'
import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
export type { Workspace } from './types.ts'
export { workspaceRecord, workspaceDomainSpec } from './spec.ts'
export type { WorkspaceRecord } from './spec.ts'
export { realpathNormalize } from './paths.ts'
/** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
export type WorkspaceId = WorkspaceIdBrand
/**
* Brand a string as a {@link WorkspaceId}.
* @param id - the raw workspace id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function WorkspaceId(id: string): WorkspaceId {
return id as WorkspaceId
}
declare module 'cordis' {
interface Context {
workspace: WorkspaceRegistry
}
}
/**
* The workspace registry service. Opens the `workspace` domain at startup,
* rebuilds one entity per stored record, and serves entities from an
* in-memory cache keyed by id. Session persistence is an OPTIONAL peer
* (resolved via `ctx.get`, never injected): while it is absent, session
* attachment rejects (what cannot be validated is not recorded) and
* `sessionIds` projections serve the account unfiltered.
*
* There is deliberately no delete entry point in this phase: workspace
* deletion ships as one complete semantic together with the session-cascade
* primitives (future work in the owning Agent Note).
*/
export class WorkspaceRegistry extends Service {
static inject = ['storage']
private table?: KvTable<WorkspaceId, WorkspaceRecord>
private readonly entities = new Map<WorkspaceId, WorkspaceEntity>()
/**
* Session ids known to exist in session persistence; `undefined` until the
* first successful listing. Refreshed at startup and on every attach
* validation — within one process sessions are only ever added (this phase
* has no delete primitive), so the set can only lag by missing very recent
* sessions, never by holding dead ones from this process's lifetime.
*/
private known?: Set<string>
private readonly host: WorkspaceEntityHost = {
table: () => this.requireTable(),
knownSessionIds: () => this.known,
readSessionHeader: id => this.readSessionHeader(id),
}
constructor(ctx: Context) {
super(ctx, 'workspace')
}
/** Open the domain and rebuild the entity cache before the service is published as active. */
protected async [Service.init](): Promise<void> {
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
this.table = domain.table('workspaces')
const persistence = this.ctx.get('sessionPersistence')
if (persistence !== undefined) {
this.known = new Set<string>((await persistence.list()).map(header => header.id))
}
// Rebuild entities, rejecting a double account: one session recorded
// under two workspaces means the medium was edited externally (the attach
// check makes it structurally impossible to write), and hiding it would
// silently pick a winner.
const accounted = new Map<string, WorkspaceId>()
for (const [id, record] of this.table.entries()) {
for (const sessionId of record.sessionIds) {
const holder = accounted.get(sessionId)
if (holder !== undefined) {
throw new Error(
`workspace domain is inconsistent: session '${sessionId}' is accounted `
+ `by both workspace '${holder}' and workspace '${id}'`,
)
}
accounted.set(sessionId, id)
}
this.entities.set(id, new WorkspaceEntity(this.host, id, record))
}
}
/**
* Create a workspace over an existing directory. The path is canonicalized
* through `fs.realpath` first — a nonexistent directory rejects with the
* original `ENOENT`, and a canonical path already owned by another
* workspace (including a symlink resolving to it) rejects.
* @param path - Directory the workspace points at; canonicalized before storing.
* @param title - Display title; defaults to `basename` of the canonical path.
* @returns the created workspace after durability.
*/
async create(path: string, title?: string): Promise<Workspace> {
const table = this.requireTable()
const canonical = await realpathNormalize(path)
for (const entity of this.entities.values()) {
if (entity.path === canonical) {
throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`)
}
}
const id = WorkspaceId(randomUUID())
const now = new Date().toISOString()
const record: WorkspaceRecord = {
path: canonical,
title: title ?? basename(canonical),
sessionIds: [],
createdAt: now,
updatedAt: now,
}
const entity = new WorkspaceEntity(this.host, id, record)
// Cache before the durable put: a concurrent same-path create fails the
// scan above, and the entity already exists when `domain/changed` fires.
this.entities.set(id, entity)
try {
await table.put(id, record)
} catch (error) {
this.entities.delete(id)
throw error
}
return entity
}
/**
* Look up a workspace by id.
* @param id - The workspace id.
* @returns the workspace, or `undefined` when unknown.
*/
get(id: WorkspaceId): Workspace | undefined {
return this.entities.get(id)
}
/**
* Snapshot of all workspaces, in load-then-creation order.
* @returns a fresh array of the cached entities.
*/
list(): Workspace[] {
return [...this.entities.values()]
}
/**
* Resolve a workspace by directory path, through the same `fs.realpath`
* canon as {@link create} (hence async). A path that does not exist rejects
* with the original error — a missing directory has no canonical form to
* compare (a workspace whose recorded directory vanished is only reachable
* by id; see `Workspace.status`).
* @param path - Directory path in any spelling (symlinks, `..`, trailing slash).
* @returns the owning workspace, or `undefined` when none matches.
*/
async resolveByPath(path: string): Promise<Workspace | undefined> {
const canonical = await realpathNormalize(path)
for (const entity of this.entities.values()) {
if (entity.path === canonical) return entity
}
return undefined
}
private requireTable(): KvTable<WorkspaceId, WorkspaceRecord> {
if (this.table === undefined) {
throw new Error('workspace registry is not started yet')
}
return this.table
}
/**
* Read one stored session header for attach validation, refreshing the
* known-session view from the same listing. Rejects when session
* persistence is absent or holds no session with this id.
*/
private async readSessionHeader(id: SessionId): Promise<SessionHeader> {
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error(
`cannot validate session '${id}': no session persistence service is available`,
)
}
const headers = await persistence.list()
this.known = new Set<string>(headers.map(header => header.id))
const header = headers.find(candidate => candidate.id === id)
if (header === undefined) {
throw new Error(`cannot validate session '${id}': session persistence holds no such session`)
}
return header
}
}
export default WorkspaceRegistry

View File

@@ -0,0 +1,53 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace`.
* @module @deepseek-ai/dsh-workspace/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { DomainChanged } from '@deepseek-ai/dsh-domain'
import { WorkspaceId } from '@deepseek-ai/dsh-workspace'
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace'
/** Cordis companion plugin name. */
export const name = 'workspace-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Owned relationship: the registry's entity cache mirrors the workspace
* domain's durable table. Every `domain/changed` for the `workspaces` table
* must name a record the cache already holds an entity for (the registry
* caches before the durable put and mutates only through cached entities),
* and no `deleted` operation may appear at all — this phase ships no delete
* entry point, so a deletion proves a write path outside the registry.
*/
const install: InvariantInstaller = Object.assign(
(ctx: Context, fail: (message: string) => never) => {
ctx.on('domain/changed', (change: DomainChanged) => {
if (change.domain !== 'workspace' || change.table !== 'workspaces') return
if (change.operation === 'deleted') {
fail(
`workspace record '${change.key}' emitted a deleted change, but the registry `
+ 'exposes no delete entry point — some write path bypassed ctx.workspace',
)
}
if (ctx.workspace.get(WorkspaceId(change.key)) === undefined) {
fail(
`workspace record '${change.key}' landed durably but the registry cache holds `
+ 'no entity for it — the cache and the domain table have diverged',
)
}
})
},
{ inject: ['workspace'] },
)
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,22 @@
/**
* Path canonicalization for workspace identity.
* @module @deepseek-ai/dsh-workspace/src/paths
*/
import { realpath } from 'node:fs/promises'
/**
* Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
* segments, and symlinks are all resolved. This is the ONE uniqueness canon of
* the package — workspace paths are stored canonicalized, uniqueness is
* string equality of canonicalized paths (a symlink to an existing
* workspace's directory collides), and attach-time session `cwd` checks go
* through the same canon. A path that does not exist rejects with the
* original `ENOENT` — this is `create`'s reject path (a workspace must point
* at an existing directory).
* @param path - The path to canonicalize.
* @returns the canonical absolute path.
*/
export async function realpathNormalize(path: string): Promise<string> {
return await realpath(path)
}

View File

@@ -0,0 +1,39 @@
/**
* The workspace domain declaration: record schema and the `defineDomain` spec
* the registry opens. The zod schema is the durable-boundary validator today
* and the direct source of the RPC wire projection in a later phase.
* @module @deepseek-ai/dsh-workspace/src/spec
*/
import { z } from 'zod'
import { SessionId } from '@deepseek-ai/dsh-session'
import { defineDomain, domainTable } from '@deepseek-ai/dsh-domain'
import type { WorkspaceId } from './types.ts'
/**
* Durable shape of one workspace record. `path` is the `fs.realpath` canon
* stamped at create; `sessionIds` is the ordered ownership account (array
* order is display order); timestamps are ISO-8601 strings.
*/
export const workspaceRecord = z.object({
path: z.string(),
title: z.string(),
sessionIds: z.array(z.string().transform(SessionId)),
createdAt: z.string(),
updatedAt: z.string(),
})
/** One stored workspace record, inferred from {@link workspaceRecord}. */
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
/**
* The workspace domain spec: one `workspaces` table keyed by
* {@link WorkspaceId}, no global singleton. The registry opens this through
* `ctx.storage.domain`; the spec object is the single source of the domain's
* identity, version, and record schema.
*/
export const workspaceDomainSpec = defineDomain({
name: 'workspace',
version: 1,
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
})

View File

@@ -0,0 +1,84 @@
/**
* Public type vocabulary of the workspace entity: the `WorkspaceId` brand and
* the `Workspace` consumer interface. Types only — the `WorkspaceId` factory
* lives in `index.ts` (this file carries no runtime code).
* @module @deepseek-ai/dsh-workspace/src/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session'
/**
* Identifies one workspace record. A generated uuid, never the path: path
* normalization rewrites paths, and a reference anchor must stay stable.
*/
export type WorkspaceId = Branded<'WorkspaceId'>
/**
* One workspace: a stable id over an existing directory, a display title, and
* the ordered account of sessions that belong to it. The account is the sole
* source of ownership — sessions are never inferred from cwd. Consumers only
* see this interface; the entity implementation stays package-private.
*/
export interface Workspace {
/** Stable record id (generated uuid). */
readonly id: WorkspaceId
/**
* Canonical directory path: the `fs.realpath` of the path given at create
* time (trailing slashes, `..`, and symlinks all resolved). Never rewritten
* afterwards, even when the directory disappears (see {@link status}).
*/
readonly path: string
/** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
readonly title: string
/**
* Sessions recorded under this workspace, in attach order (the array order
* is the display order). A projection: accounted ids whose session no
* longer exists in session persistence are filtered out here (and dropped
* from the durable account on the next mutation); when session persistence
* is absent the account is served unfiltered because membership cannot be
* verified.
*/
readonly sessionIds: readonly SessionId[]
/**
* Replace the display title durably.
* @param title - New title; any string, duplicates across workspaces allowed.
* @returns resolution after durability.
*/
setTitle(title: string): Promise<void>
/**
* Record a session under this workspace. Idempotent: a session already on
* the account resolves without writing. Otherwise the session's stored
* header is read from session persistence and its `cwd`, normalized through
* the same `fs.realpath` canon as workspace paths, must equal this
* workspace's {@link path} — a missing persistence service, an unknown
* session id, a header without `cwd`, a `cwd` that no longer resolves, or a
* mismatched `cwd` all reject without touching the account (what cannot be
* validated is not recorded).
* @param sessionId - The session to record.
* @returns resolution after durability.
*/
attachSession(sessionId: SessionId): Promise<void>
/**
* Remove a session from this workspace's account. Idempotent: an id not on
* the account resolves without writing. Never touches the session's own
* stored log.
* @param sessionId - The session to remove.
* @returns resolution after durability.
*/
detachSession(sessionId: SessionId): Promise<void>
/**
* Live directory check, uncached: whether {@link path} currently exists and
* is a directory. A missing directory never mutates the record — the
* directory may only be temporarily moved.
* @returns `'ok'` when the directory exists, `'missing-dir'` otherwise.
*/
status(): Promise<'ok' | 'missing-dir'>
}

View File

@@ -0,0 +1,240 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { Context } from 'cordis'
import { apply as applyStorage } 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'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/domain/tests/helpers/memory-backend.ts'
import WorkspaceRegistry, { WorkspaceId } from '../src/index.ts'
import type { WorkspaceRecord } from '../src/index.ts'
const header = (id: string, cwd?: string): SessionHeader =>
({ version: 0, id: SessionId(id), createdAt: 0, ...(cwd === undefined ? {} : { cwd }) })
/**
* Boot storage hub + memory backend + domain form + the workspace registry.
* `sessions: 'absent'` boots without a sessionPersistence service; otherwise
* a stub serving exactly the given headers from `list()` is provided, and
* `setSessions` swaps what it serves next.
*/
async function harness(options?: {
pool?: MemoryMediaPool
sessions?: SessionHeader[] | 'absent'
}) {
const ctx = new Context()
await ctx.plugin({ apply: applyStorage })
ctx.storage.backend.register('memory', 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) {
ctx.provide('sessionPersistence', { list: async () => listed ?? [] })
}
const changes: DomainChanged[] = []
ctx.on('domain/changed', (change) => { changes.push(change) })
await ctx.plugin(WorkspaceRegistry)
return {
ctx,
registry: ctx.workspace,
changes,
setSessions: (headers: SessionHeader[]) => { listed = headers },
}
}
/** A pool pre-stamped with one stored workspace record, simulating a prior run. */
function pooledRecord(id: string, record: WorkspaceRecord): MemoryMediaPool {
const pool = new MemoryMediaPool()
pool.versions.set('workspace', 1)
pool.media.set('workspace', {
tables: new Map([['workspaces', new Map<string, unknown>([[id, record]])]]),
global: null,
})
return pool
}
const record = (path: string, sessionIds: string[]): WorkspaceRecord => ({
path,
title: basename(path),
sessionIds: sessionIds.map(SessionId),
createdAt: '2026-07-24T00:00:00.000Z',
updatedAt: '2026-07-24T00:00:00.000Z',
})
/** Stored record as the memory medium currently holds it. */
function storedRecord(pool: MemoryMediaPool, id: string): WorkspaceRecord {
return pool.media.get('workspace')!.tables.get('workspaces')!.get(id) as WorkspaceRecord
}
let base: string
const tempDirs: string[] = []
/** A fresh real directory under a canonicalized temp base. */
async function makeDir(name: string): Promise<string> {
base ??= await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-')))
if (tempDirs.length === 0) tempDirs.push(base)
const dir = join(base, name)
await mkdir(dir, { recursive: true })
return dir
}
afterEach(async () => {
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
base = undefined as never
})
describe('WorkspaceRegistry.create', () => {
it('stores the canonical path, defaults the title to basename, and lists the entity', async () => {
const dir = await makeDir('proj')
const { registry } = await harness()
const workspace = await registry.create(dir + '/')
expect(workspace.path).toBe(dir)
expect(workspace.title).toBe('proj')
expect(workspace.sessionIds).toEqual([])
expect(registry.list()).toEqual([workspace])
expect(registry.get(workspace.id)).toBe(workspace)
const titled = await registry.create(await makeDir('other'), 'Custom')
expect(titled.title).toBe('Custom')
})
it('rejects a nonexistent directory with the original ENOENT', async () => {
const dir = await makeDir('exists')
const { registry } = await harness()
await expect(registry.create(join(dir, 'nope'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(registry.list()).toEqual([])
})
it('rejects a duplicate path, including a symlink resolving to an existing workspace', async () => {
const dir = await makeDir('real')
const link = join(base, 'link')
await symlink(dir, link)
const { registry } = await harness()
await registry.create(dir)
await expect(registry.create(link)).rejects.toThrow(/already exists/)
expect(registry.list()).toHaveLength(1)
})
it('resolves by path through the same canon', async () => {
const dir = await makeDir('canon')
const link = join(base, 'canon-link')
await symlink(dir, link)
const { registry } = await harness()
const workspace = await registry.create(dir)
expect(await registry.resolveByPath(link)).toBe(workspace)
expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined()
})
})
describe('Workspace.attachSession', () => {
it('attaches when the session cwd resolves to the workspace path, keeping attach order', async () => {
const dir = await makeDir('attach')
const link = join(base, 'attach-link')
await symlink(dir, link)
// s2's cwd is spelled through the symlink: same canon, must attach.
const { registry } = await harness({
sessions: [header('s1', dir), header('s2', link), header('s3', dir)],
})
const workspace = await registry.create(dir)
await workspace.attachSession(SessionId('s1'))
await workspace.attachSession(SessionId('s2'))
await workspace.attachSession(SessionId('s3'))
expect(workspace.sessionIds).toEqual(['s1', 's2', 's3'])
await workspace.detachSession(SessionId('s2'))
expect(workspace.sessionIds).toEqual(['s1', 's3'])
})
it('rejects a cwd resolving elsewhere, a missing cwd, and an unknown session', async () => {
const dir = await makeDir('strict')
const elsewhere = await makeDir('elsewhere')
const { registry } = await harness({
sessions: [header('other-dir', elsewhere), header('no-cwd', undefined)],
})
const workspace = await registry.create(dir)
await expect(workspace.attachSession(SessionId('other-dir'))).rejects.toThrow(/resolves to/)
await expect(workspace.attachSession(SessionId('no-cwd'))).rejects.toThrow(/no cwd/)
await expect(workspace.attachSession(SessionId('unknown'))).rejects.toThrow(/no such session/)
expect(workspace.sessionIds).toEqual([])
})
it('rejects a cwd that no longer resolves', async () => {
const dir = await makeDir('target')
const gone = await makeDir('gone')
const { registry } = await harness({ sessions: [header('s1', gone)] })
const workspace = await registry.create(dir)
await rm(gone, { recursive: true })
await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/does not resolve/)
})
it('rejects every attach while session persistence is absent', async () => {
const dir = await makeDir('no-persistence')
const { registry } = await harness({ sessions: 'absent' })
const workspace = await registry.create(dir)
await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/no session persistence/)
})
it('is idempotent on both attach and detach — a no-op never writes', async () => {
const dir = await makeDir('idem')
const { registry, changes, setSessions } = await harness({ sessions: [header('s1', dir)] })
const workspace = await registry.create(dir)
await workspace.attachSession(SessionId('s1'))
const written = changes.length
// Re-attaching skips validation entirely: even with the session gone from
// the listing, the id already being on the account resolves without IO.
setSessions([])
await workspace.attachSession(SessionId('s1'))
await workspace.detachSession(SessionId('absent'))
expect(changes.length).toBe(written)
})
})
describe('consistency projections', () => {
it('filters accounted ids with no stored session and prunes them on the next mutation', async () => {
const dir = await makeDir('stale')
const id = WorkspaceId('00000000-0000-4000-8000-000000000001')
const pool = pooledRecord(id, record(dir, ['live', 'ghost']))
const { registry } = await harness({ pool, sessions: [header('live', dir)] })
const workspace = registry.get(id)!
// Rule 1: the projection hides the dead id; the durable account still holds it.
expect(workspace.sessionIds).toEqual(['live'])
expect(storedRecord(pool, id).sessionIds).toEqual(['live', 'ghost'])
// Any mutation prunes it durably.
await workspace.setTitle('renamed')
expect(storedRecord(pool, id).sessionIds).toEqual(['live'])
expect(workspace.title).toBe('renamed')
})
it('serves the account unfiltered while session persistence is absent', async () => {
const dir = await makeDir('unverifiable')
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'])
})
it('rejects startup over a medium accounting one session twice', async () => {
const dir = await makeDir('double')
const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dir, ['dup']))
pool.media.get('workspace')!.tables.get('workspaces')!
.set('00000000-0000-4000-8000-000000000004', record(dir, ['dup']))
const ctx = new Context()
await ctx.plugin({ apply: applyStorage })
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/accounted/)
})
})
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')
const { registry } = await harness()
const workspace = await registry.create(dir)
expect(await workspace.status()).toBe('ok')
await rm(dir, { recursive: true })
expect(await workspace.status()).toBe('missing-dir')
expect(workspace.path).toBe(dir)
expect(registry.get(workspace.id)).toBe(workspace)
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../storage/storage"
},
{
"path": "../../storage/domain"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}
]
}