From 013e6f876967311a1ba55a17ee65ed9e27ba0879 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:07:27 +0800 Subject: [PATCH] feat(workspace): persistent workspace entity over the domain form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/workspace/workspace/README.md | 25 ++ packages/workspace/workspace/package.json | 50 ++++ packages/workspace/workspace/src/entity.ts | 150 +++++++++++ packages/workspace/workspace/src/index.ts | 213 ++++++++++++++++ packages/workspace/workspace/src/invariant.ts | 53 ++++ packages/workspace/workspace/src/paths.ts | 22 ++ packages/workspace/workspace/src/spec.ts | 39 +++ packages/workspace/workspace/src/types.ts | 84 ++++++ .../workspace/tests/workspace.spec.ts | 240 ++++++++++++++++++ packages/workspace/workspace/tsconfig.json | 39 +++ 10 files changed, 915 insertions(+) create mode 100644 packages/workspace/workspace/README.md create mode 100644 packages/workspace/workspace/package.json create mode 100644 packages/workspace/workspace/src/entity.ts create mode 100644 packages/workspace/workspace/src/index.ts create mode 100644 packages/workspace/workspace/src/invariant.ts create mode 100644 packages/workspace/workspace/src/paths.ts create mode 100644 packages/workspace/workspace/src/spec.ts create mode 100644 packages/workspace/workspace/src/types.ts create mode 100644 packages/workspace/workspace/tests/workspace.spec.ts create mode 100644 packages/workspace/workspace/tsconfig.json diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md new file mode 100644 index 0000000000..09fc07d4bc --- /dev/null +++ b/packages/workspace/workspace/README.md @@ -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. diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json new file mode 100644 index 0000000000..69244e5c3b --- /dev/null +++ b/packages/workspace/workspace/package.json @@ -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" + } +} diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts new file mode 100644 index 0000000000..de70917cfb --- /dev/null +++ b/packages/workspace/workspace/src/entity.ts @@ -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 + + /** + * 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 | 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 +} + +/** 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 { + await this.mutate(record => ({ ...record, title })) + } + + async attachSession(sessionId: SessionId): Promise { + 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 { + 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 { + 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(), + } + }) + } +} diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts new file mode 100644 index 0000000000..be5bb791a8 --- /dev/null +++ b/packages/workspace/workspace/src/index.ts @@ -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 + private readonly entities = new Map() + /** + * 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 + + 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 { + 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((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() + 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 { + 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 { + const canonical = await realpathNormalize(path) + for (const entity of this.entities.values()) { + if (entity.path === canonical) return entity + } + return undefined + } + + private requireTable(): KvTable { + 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 { + 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(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 diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts new file mode 100644 index 0000000000..f682bdddf6 --- /dev/null +++ b/packages/workspace/workspace/src/invariant.ts @@ -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)) diff --git a/packages/workspace/workspace/src/paths.ts b/packages/workspace/workspace/src/paths.ts new file mode 100644 index 0000000000..610ded0e06 --- /dev/null +++ b/packages/workspace/workspace/src/paths.ts @@ -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 { + return await realpath(path) +} diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts new file mode 100644 index 0000000000..c249ba02d0 --- /dev/null +++ b/packages/workspace/workspace/src/spec.ts @@ -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 + +/** + * 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(workspaceRecord) }, +}) diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts new file mode 100644 index 0000000000..197273fbb7 --- /dev/null +++ b/packages/workspace/workspace/src/types.ts @@ -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 + + /** + * 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 + + /** + * 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 + + /** + * 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'> +} diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts new file mode 100644 index 0000000000..eb42ec2850 --- /dev/null +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -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([[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 { + 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) + }) +}) diff --git a/packages/workspace/workspace/tsconfig.json b/packages/workspace/workspace/tsconfig.json new file mode 100644 index 0000000000..b6254015a3 --- /dev/null +++ b/packages/workspace/workspace/tsconfig.json @@ -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" + } + ] +}