feat(web): add workspace-aware session flow
This commit is contained in:
@@ -1,18 +1,19 @@
|
||||
# @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.
|
||||
Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records, stable workspace order, and a newest-first candidate session index stored through the domain data form. Consumers see the `Workspace` interface; 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).
|
||||
The entity/storage rationale lives in the [domain Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); header-only bootstrap and GUI ordering live in the [Workspace GUI Agent Note](../../../.agents/notes/proposed/feature/2026-07-25-workspace-gui-and-session-drafts.md).
|
||||
|
||||
## Shape
|
||||
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath` (trailing slashes, `..`, symlinks), rejects a nonexistent path (the original `ENOENT`), a path resolving to anything but a directory, 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, or claiming one canonical path from two records, rejects at startup (external edit — the write side makes both unreachable). Attach/detach idempotence is decided on the domain write chain, so unawaited concurrent calls settle in call order.
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
|
||||
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
|
||||
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.
|
||||
- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes.
|
||||
- `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup.
|
||||
- `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.
|
||||
`storageDomain` and `sessionPersistence` are required startup dependencies. An unavailable peer leaves the plugin pending and cannot commit an empty initialized marker. On the first successful start, the registry calls `SessionPersistence.list()` and uses only header `id`, `cwd`, and `createdAt` to group valid historical directories and persist initial order; it never reads event bodies. The initialized marker is written last, so partial bootstrap writes are reused safely after restart. Later cwd-only sessions remain Ungrouped.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -33,5 +34,4 @@ Independent of live requests: the package never touches a request prefix, so it
|
||||
## 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.
|
||||
- The header index refreshes at startup and when attach must resolve an uncached persisted id; deletion or cwd damage performed by another process is observed after the next refresh or restart.
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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.
|
||||
* `updatedAt` stamping and invalid-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
|
||||
@@ -17,8 +17,8 @@ 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.
|
||||
* the registry itself — only the open table, the canonical session-path
|
||||
* index backing the `sessionIds` projection, and attach-time header reads.
|
||||
*/
|
||||
export interface WorkspaceEntityHost {
|
||||
/**
|
||||
@@ -28,13 +28,12 @@ export interface WorkspaceEntityHost {
|
||||
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).
|
||||
* Read a session's canonical directory from the registry's header index.
|
||||
* @param id - Session whose indexed path is requested.
|
||||
* @returns the canonical directory, or `undefined` when the header is
|
||||
* missing or its cwd cannot identify an existing directory.
|
||||
*/
|
||||
knownSessionIds(): ReadonlySet<string> | undefined
|
||||
sessionPath(id: SessionId): string | undefined
|
||||
|
||||
/**
|
||||
* Read one stored session header for attach validation.
|
||||
@@ -43,6 +42,13 @@ export interface WorkspaceEntityHost {
|
||||
* no session with this id.
|
||||
*/
|
||||
readSessionHeader(id: SessionId): Promise<SessionHeader>
|
||||
|
||||
/**
|
||||
* Publish a successfully validated canonical cwd to the projection index.
|
||||
* @param id - Validated session id.
|
||||
* @param path - Canonical existing directory from the immutable header cwd.
|
||||
*/
|
||||
rememberSessionPath(id: SessionId, path: string): void
|
||||
}
|
||||
|
||||
/** Chain-slot abort sentinel thrown by the update fn when the record needs no change; only `mutate` observes it. */
|
||||
@@ -53,7 +59,7 @@ export class WorkspaceEntity implements Workspace {
|
||||
private record: WorkspaceRecord
|
||||
|
||||
/**
|
||||
* @param host - Registry-owned table, known-session view, and header reads.
|
||||
* @param host - Registry-owned table, session-path index, and header reads.
|
||||
* @param id - The record's stable id.
|
||||
* @param record - The validated record snapshot loaded or just written.
|
||||
*/
|
||||
@@ -73,10 +79,16 @@ export class WorkspaceEntity implements Workspace {
|
||||
return this.record.title
|
||||
}
|
||||
|
||||
get createdAt(): string {
|
||||
return this.record.createdAt
|
||||
}
|
||||
|
||||
get updatedAt(): string {
|
||||
return this.record.updatedAt
|
||||
}
|
||||
|
||||
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))
|
||||
return this.record.sessionIds.filter(id => this.host.sessionPath(id) === this.record.path)
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
@@ -106,16 +118,49 @@ export class WorkspaceEntity implements Workspace {
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (!(await stat(cwd)).isDirectory()) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd '${header.cwd}' is not a directory`,
|
||||
)
|
||||
}
|
||||
if (cwd !== this.record.path) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd resolves to '${cwd}'`,
|
||||
)
|
||||
}
|
||||
this.host.rememberSessionPath(sessionId, cwd)
|
||||
}
|
||||
await this.mutate(record => record.sessionIds.includes(sessionId)
|
||||
? record
|
||||
: { ...record, sessionIds: [...record.sessionIds, sessionId] })
|
||||
: { ...record, sessionIds: [sessionId, ...record.sessionIds] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the durable candidate account without applying header projection.
|
||||
* @param sessionId - Candidate session id.
|
||||
* @returns whether this workspace's stored account contains the id.
|
||||
*/
|
||||
hasSession(sessionId: SessionId): boolean {
|
||||
return this.record.sessionIds.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one validated accounted session to the front without touching peers.
|
||||
* @param sessionId - Accounted session whose activity was observed.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
if (
|
||||
this.host.sessionPath(sessionId) !== this.record.path
|
||||
|| this.record.sessionIds[0] === sessionId
|
||||
) return
|
||||
await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId
|
||||
? record
|
||||
: {
|
||||
...record,
|
||||
sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)],
|
||||
})
|
||||
}
|
||||
|
||||
async detachSession(sessionId: SessionId): Promise<void> {
|
||||
@@ -136,9 +181,9 @@ export class WorkspaceEntity implements Workspace {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `table.update`, stamping `updatedAt` and pruning candidates that no
|
||||
* longer pass the id-plus-canonical-cwd membership check, then swap the
|
||||
* snapshot.
|
||||
*
|
||||
* `fn` sees the value current at its chain slot, so membership decisions
|
||||
* (attach/detach idempotence) are race-free against queued writes; a fn
|
||||
@@ -147,14 +192,13 @@ export class WorkspaceEntity implements Workspace {
|
||||
* rewrites the medium nor emits a change event.
|
||||
*/
|
||||
private async mutate(fn: (record: WorkspaceRecord) => WorkspaceRecord): Promise<void> {
|
||||
const known = this.host.knownSessionIds()
|
||||
let next: WorkspaceRecord
|
||||
try {
|
||||
next = await this.host.table().update(this.id, (current) => {
|
||||
const changed = fn(current)
|
||||
const sessionIds = known === undefined
|
||||
? changed.sessionIds
|
||||
: changed.sessionIds.filter(id => known.has(id))
|
||||
const sessionIds = changed.sessionIds.filter(
|
||||
id => this.host.sessionPath(id) === changed.path,
|
||||
)
|
||||
if (changed === current && sessionIds.length === current.sessionIds.length) {
|
||||
throw unchangedSentinel
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* Workspace entity registry (`ctx.workspace`): durable workspace records,
|
||||
* stable registry order, and header-validated session membership over the
|
||||
* domain data form.
|
||||
* @module @deepseek-ai/dsh-workspace
|
||||
*/
|
||||
|
||||
@@ -11,20 +10,18 @@ import { stat } from 'node:fs/promises'
|
||||
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-storage-domain'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceRecord } from './spec.ts'
|
||||
import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { WorkspaceEntity } from './entity.ts'
|
||||
import type { WorkspaceEntityHost } from './entity.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from './spec.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 { workspaceDomainState, workspaceRecord, workspaceDomainSpec } from './spec.ts'
|
||||
export type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
export { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
|
||||
@@ -32,74 +29,345 @@ 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).
|
||||
* @param id - Raw workspace id string.
|
||||
* @returns the same string, branded at compile time.
|
||||
*/
|
||||
export function WorkspaceId(id: string): WorkspaceId {
|
||||
return id as WorkspaceId
|
||||
}
|
||||
|
||||
/** A create request would give two Workspaces the same display name. */
|
||||
export class WorkspaceNameConflictError extends Error {
|
||||
/**
|
||||
* @param workspaceName - Conflicting display name.
|
||||
*/
|
||||
constructor(readonly workspaceName: string) {
|
||||
super(`workspace name '${workspaceName}' is already in use`)
|
||||
this.name = 'WorkspaceNameConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workspace: WorkspaceRegistry
|
||||
}
|
||||
}
|
||||
|
||||
interface BootstrapGroup {
|
||||
readonly path: string
|
||||
readonly headers: SessionHeader[]
|
||||
readonly newestAt: number
|
||||
}
|
||||
|
||||
const sameIds = (left: readonly WorkspaceId[], right: readonly WorkspaceId[]): boolean =>
|
||||
left.length === right.length && left.every((id, index) => id === right[index])
|
||||
|
||||
const compareHeaders = (left: SessionHeader, right: SessionHeader): number =>
|
||||
right.createdAt - left.createdAt || String(left.id).localeCompare(String(right.id))
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Durable workspace registry. Startup waits for `sessionPersistence`, builds
|
||||
* one canonical-cwd header index, and completes the one-time history
|
||||
* bootstrap before the service becomes active. The persistence dependency is
|
||||
* mandatory so an unavailable peer can never be mistaken for an empty
|
||||
* history and commit the initialized marker.
|
||||
*/
|
||||
export class WorkspaceRegistry extends Service {
|
||||
static inject = ['storage']
|
||||
static inject = ['storageDomain', 'sessionPersistence']
|
||||
|
||||
private table?: KvTable<WorkspaceId, WorkspaceRecord>
|
||||
private global?: DomainGlobal<WorkspaceDomainState>
|
||||
private state?: WorkspaceDomainState
|
||||
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 headers = new Map<SessionId, SessionHeader>()
|
||||
private readonly sessionPaths = new Map<SessionId, string>()
|
||||
private readonly invalidSessionPaths = new Map<SessionId, string>()
|
||||
private readonly pendingTouches = new Map<SessionId, Promise<void>>()
|
||||
private operationTail: Promise<void> = Promise.resolve()
|
||||
|
||||
private readonly host: WorkspaceEntityHost = {
|
||||
table: () => this.requireTable(),
|
||||
knownSessionIds: () => this.known,
|
||||
sessionPath: id => this.sessionPaths.get(id),
|
||||
readSessionHeader: id => this.readSessionHeader(id),
|
||||
rememberSessionPath: (id, path) => {
|
||||
this.sessionPaths.set(id, path)
|
||||
this.invalidSessionPaths.delete(id)
|
||||
},
|
||||
}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workspace')
|
||||
}
|
||||
|
||||
/** Open the domain and rebuild the entity cache before the service is published as active. */
|
||||
/** Open the domain, finish bootstrap when required, and rebuild the ordered cache. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// This registry owns the domain handle it opened: closing on fiber
|
||||
// disposal frees the domain name, so a re-plugged registry can reopen it.
|
||||
const domain = await this.ctx.storageDomain.open(workspaceDomainSpec)
|
||||
this.ctx.effect(() => () => domain.close(), 'workspace.domainClose')
|
||||
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))
|
||||
this.global = domain.global
|
||||
this.state = domain.global.get()
|
||||
|
||||
this.validateStoredState(this.state)
|
||||
if (!this.state.initialized) {
|
||||
const headers = await this.ctx.sessionPersistence.list()
|
||||
await this.replaceHeaderIndex(headers)
|
||||
await this.bootstrap(headers)
|
||||
} else if (this.table.size > 0) {
|
||||
await this.replaceHeaderIndex(await this.ctx.sessionPersistence.list())
|
||||
}
|
||||
// Rebuild entities, rejecting states the write side makes structurally
|
||||
// impossible (an external medium edit is the only way in, and hiding it
|
||||
// would silently pick a winner): one session accounted under two
|
||||
// workspaces, or two records claiming one canonical path (plain string
|
||||
// equality — stored paths are already canonical, so no realpath here).
|
||||
const accounted = new Map<string, WorkspaceId>()
|
||||
|
||||
await this.indexLiveSessions()
|
||||
this.validateStoredState(this.requireState())
|
||||
this.rebuildEntities()
|
||||
this.reportFilteredCandidates()
|
||||
// Session activity is authoritative even when no RPC/SSE consumer is
|
||||
// connected. This service-owned listener is disposed with the registry.
|
||||
this.ctx.on('session/event', (session) => {
|
||||
void this.touchSession(session.id).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or reuse a workspace for an existing directory. The path is
|
||||
* canonicalized through `fs.realpath`; a nonexistent path rejects with the
|
||||
* original error and a non-directory rejects. Repeated calls for the same
|
||||
* canonical path return the existing entity without changing its title.
|
||||
* A newly created workspace is prepended to the durable registry order.
|
||||
* A different canonical path cannot create a duplicate display title.
|
||||
* @param path - Existing directory to own, in any path spelling.
|
||||
* @param title - Display title used only when a new record is created.
|
||||
* @returns the existing or newly durable workspace.
|
||||
*/
|
||||
async create(path: string, title?: string): Promise<Workspace> {
|
||||
const canonical = await realpathNormalize(path)
|
||||
if (!(await stat(canonical)).isDirectory()) {
|
||||
throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`)
|
||||
}
|
||||
return await this.enqueueOperation(() => this.createCanonical(canonical, title))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a workspace by id.
|
||||
* @param id - Workspace id.
|
||||
* @returns the workspace, or `undefined` when unknown.
|
||||
*/
|
||||
get(id: WorkspaceId): Workspace | undefined {
|
||||
return this.entities.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous workspace projection in durable registry order. Every
|
||||
* entity's `sessionIds` getter is already filtered by the startup/live
|
||||
* canonical-cwd header index; this method performs no persistence reads.
|
||||
* @returns a fresh ordered array of workspace entities.
|
||||
*/
|
||||
list(): Workspace[] {
|
||||
return this.requireState().workspaceIds.map((id) => {
|
||||
const entity = this.entities.get(id)
|
||||
if (entity === undefined) {
|
||||
throw new Error(`workspace registry order references missing workspace '${id}'`)
|
||||
}
|
||||
return entity
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one accounted, cwd-validated session to the front of its workspace.
|
||||
* Ungrouped sessions and candidates filtered by the header check are
|
||||
* no-ops. The owning workspace's relative position never changes.
|
||||
* @param sessionId - Session whose activity was observed.
|
||||
* @returns resolution after the possible record write.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
const pending = this.pendingTouches.get(sessionId)
|
||||
if (pending !== undefined) {
|
||||
await pending
|
||||
return
|
||||
}
|
||||
for (const entity of this.entities.values()) {
|
||||
if (!entity.hasSession(sessionId)) continue
|
||||
const touch = entity.touchSession(sessionId)
|
||||
this.pendingTouches.set(sessionId, touch)
|
||||
try {
|
||||
await touch
|
||||
} finally {
|
||||
this.pendingTouches.delete(sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
* directory returns `undefined`.
|
||||
* @param path - Existing directory path in any spelling.
|
||||
* @returns the workspace owning the canonical path, when one exists.
|
||||
*/
|
||||
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 async createCanonical(canonical: string, title?: string): Promise<WorkspaceEntity> {
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) return entity
|
||||
}
|
||||
|
||||
const workspaceName = title ?? basename(canonical)
|
||||
if ([...this.entities.values()].some(entity => entity.title === workspaceName)) {
|
||||
throw new WorkspaceNameConflictError(workspaceName)
|
||||
}
|
||||
|
||||
const table = this.requireTable()
|
||||
const state = this.requireState()
|
||||
const id = WorkspaceId(randomUUID())
|
||||
const now = new Date().toISOString()
|
||||
const record: WorkspaceRecord = {
|
||||
path: canonical,
|
||||
title: workspaceName,
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
this.entities.set(id, entity)
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] })
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
try {
|
||||
await table.delete(id)
|
||||
} catch (rollbackError) {
|
||||
this.entities.set(id, entity)
|
||||
throw new AggregateError(
|
||||
[error, rollbackError],
|
||||
`workspace '${id}' was stored but its registry order and rollback both failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
|
||||
const table = this.requireTable()
|
||||
const state = this.requireState()
|
||||
const groupsByPath = new Map<string, SessionHeader[]>()
|
||||
for (const header of headers) {
|
||||
const path = this.sessionPaths.get(header.id)
|
||||
if (path === undefined) continue
|
||||
const group = groupsByPath.get(path)
|
||||
if (group === undefined) groupsByPath.set(path, [header])
|
||||
else group.push(header)
|
||||
}
|
||||
const groups: BootstrapGroup[] = [...groupsByPath].map(([path, groupHeaders]) => {
|
||||
groupHeaders.sort(compareHeaders)
|
||||
const newest = groupHeaders[0] as SessionHeader
|
||||
return { path, headers: groupHeaders, newestAt: newest.createdAt }
|
||||
}).sort((left, right) =>
|
||||
right.newestAt - left.newestAt || left.path.localeCompare(right.path))
|
||||
|
||||
const byPath = new Map<string, WorkspaceId>()
|
||||
const accounted = new Map<SessionId, WorkspaceId>()
|
||||
for (const [id, record] of table.entries()) {
|
||||
byPath.set(record.path, id)
|
||||
for (const sessionId of record.sessionIds) accounted.set(sessionId, id)
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
let id = byPath.get(group.path)
|
||||
if (id === undefined) {
|
||||
const sessionIds = group.headers
|
||||
.map(header => header.id)
|
||||
.filter(sessionId => !accounted.has(sessionId))
|
||||
if (sessionIds.length === 0) continue
|
||||
id = WorkspaceId(randomUUID())
|
||||
const createdAt = new Date(group.newestAt).toISOString()
|
||||
const record: WorkspaceRecord = {
|
||||
path: group.path,
|
||||
title: basename(group.path),
|
||||
sessionIds,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
}
|
||||
await table.put(id, record)
|
||||
byPath.set(group.path, id)
|
||||
for (const sessionId of sessionIds) accounted.set(sessionId, id)
|
||||
continue
|
||||
}
|
||||
|
||||
const current = table.get(id) as WorkspaceRecord
|
||||
const historical = group.headers
|
||||
.map(header => header.id)
|
||||
.filter(sessionId => accounted.get(sessionId) === undefined || accounted.get(sessionId) === id)
|
||||
const historicalSet = new Set(historical)
|
||||
const sessionIds = [
|
||||
...historical,
|
||||
...current.sessionIds.filter(sessionId => !historicalSet.has(sessionId)),
|
||||
]
|
||||
if (sameSessionIds(current.sessionIds, sessionIds)) continue
|
||||
await table.update(id, record => ({
|
||||
...record,
|
||||
sessionIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}))
|
||||
for (const sessionId of historical) accounted.set(sessionId, id)
|
||||
}
|
||||
|
||||
const groupRank = new Map(groups.map(group => [group.path, group.newestAt]))
|
||||
const priorRank = new Map(state.workspaceIds.map((id, index) => [id, index]))
|
||||
const workspaceIds = [...table.entries()]
|
||||
.sort(([leftId, left], [rightId, right]) => {
|
||||
const leftTime = groupRank.get(left.path) ?? Date.parse(left.createdAt)
|
||||
const rightTime = groupRank.get(right.path) ?? Date.parse(right.createdAt)
|
||||
return rightTime - leftTime
|
||||
|| (priorRank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
|
||||
- (priorRank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
|
||||
|| String(leftId).localeCompare(String(rightId))
|
||||
})
|
||||
.map(([id]) => id)
|
||||
|
||||
if (!sameIds(state.workspaceIds, workspaceIds)) {
|
||||
await this.setState({ initialized: false, workspaceIds })
|
||||
}
|
||||
await this.setState({ initialized: true, workspaceIds })
|
||||
}
|
||||
|
||||
private validateStoredState(state: WorkspaceDomainState): void {
|
||||
const table = this.requireTable()
|
||||
const order = new Set<WorkspaceId>()
|
||||
for (const id of state.workspaceIds) {
|
||||
if (order.has(id)) {
|
||||
throw new Error(`workspace domain is inconsistent: registry order repeats workspace '${id}'`)
|
||||
}
|
||||
if (table.get(id) === undefined) {
|
||||
throw new Error(`workspace domain is inconsistent: registry order references missing workspace '${id}'`)
|
||||
}
|
||||
order.add(id)
|
||||
}
|
||||
if (state.initialized && order.size !== table.size) {
|
||||
const orphan = [...table.keys()].find(id => !order.has(id))
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: workspace '${orphan as WorkspaceId}' is absent from registry order`,
|
||||
)
|
||||
}
|
||||
|
||||
const paths = new Map<string, WorkspaceId>()
|
||||
for (const [id, record] of this.table.entries()) {
|
||||
const accounted = new Map<SessionId, WorkspaceId>()
|
||||
for (const [id, record] of table.entries()) {
|
||||
const pathHolder = paths.get(record.path)
|
||||
if (pathHolder !== undefined) {
|
||||
throw new Error(
|
||||
@@ -118,114 +386,112 @@ export class WorkspaceRegistry extends Service {
|
||||
}
|
||||
accounted.set(sessionId, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildEntities(): void {
|
||||
this.entities.clear()
|
||||
for (const id of this.requireState().workspaceIds) {
|
||||
const record = this.requireTable().get(id) as WorkspaceRecord
|
||||
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 path rejects with the
|
||||
* original `ENOENT`, a path resolving to anything but a directory rejects,
|
||||
* 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)
|
||||
if (!(await stat(canonical)).isDirectory()) {
|
||||
throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`)
|
||||
private async replaceHeaderIndex(headers: readonly SessionHeader[]): Promise<void> {
|
||||
this.headers.clear()
|
||||
this.sessionPaths.clear()
|
||||
this.invalidSessionPaths.clear()
|
||||
await this.indexHeaders(headers)
|
||||
}
|
||||
|
||||
private async indexHeaders(headers: readonly SessionHeader[]): Promise<void> {
|
||||
for (const header of headers) await this.indexHeader(header)
|
||||
}
|
||||
|
||||
private async indexHeader(header: SessionHeader): Promise<void> {
|
||||
this.headers.set(header.id, header)
|
||||
this.sessionPaths.delete(header.id)
|
||||
if (header.cwd === undefined) {
|
||||
this.invalidSessionPaths.set(header.id, 'header has no cwd')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const path = await realpathNormalize(header.cwd)
|
||||
if (!(await stat(path)).isDirectory()) {
|
||||
this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' is not a directory`)
|
||||
return
|
||||
}
|
||||
this.sessionPaths.set(header.id, path)
|
||||
this.invalidSessionPaths.delete(header.id)
|
||||
} catch {
|
||||
this.invalidSessionPaths.set(header.id, `cwd '${header.cwd}' does not resolve`)
|
||||
}
|
||||
}
|
||||
|
||||
private async indexLiveSessions(): Promise<void> {
|
||||
const sessions = this.ctx.get('sessions')
|
||||
if (sessions === undefined) return
|
||||
await this.indexHeaders(sessions.list().map(session => session.header))
|
||||
}
|
||||
|
||||
private reportFilteredCandidates(): void {
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) {
|
||||
throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`)
|
||||
const record = this.requireTable().get(entity.id) as WorkspaceRecord
|
||||
for (const sessionId of record.sessionIds) {
|
||||
const path = this.sessionPaths.get(sessionId)
|
||||
if (path === record.path) continue
|
||||
const reason = this.invalidSessionPaths.get(sessionId)
|
||||
?? (this.headers.has(sessionId)
|
||||
? `canonical cwd '${path}' differs from workspace path '${record.path}'`
|
||||
: 'session header is missing')
|
||||
this.ctx.logger.warn(
|
||||
`workspace '${entity.id}' filtered session '${sessionId}' from membership: ${reason}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
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 live = this.ctx.get('sessions')?.get(id)
|
||||
if (live !== undefined) {
|
||||
this.headers.set(id, live.header)
|
||||
return live.header
|
||||
}
|
||||
const headers = await persistence.list()
|
||||
this.known = new Set<string>(headers.map(header => header.id))
|
||||
const header = headers.find(candidate => candidate.id === id)
|
||||
const cached = this.headers.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const headers = await this.ctx.sessionPersistence.list()
|
||||
await this.indexHeaders(headers)
|
||||
const header = this.headers.get(id)
|
||||
if (header === undefined) {
|
||||
throw new Error(`cannot validate session '${id}': session persistence holds no such session`)
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
private requireTable(): KvTable<WorkspaceId, WorkspaceRecord> {
|
||||
if (this.table === undefined) throw new Error('workspace registry is not started yet')
|
||||
return this.table
|
||||
}
|
||||
|
||||
private requireState(): WorkspaceDomainState {
|
||||
if (this.state === undefined) throw new Error('workspace registry is not started yet')
|
||||
return this.state
|
||||
}
|
||||
|
||||
private async setState(state: WorkspaceDomainState): Promise<void> {
|
||||
await (this.global as DomainGlobal<WorkspaceDomainState>).set(state)
|
||||
this.state = state
|
||||
}
|
||||
|
||||
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operationTail.then(operation)
|
||||
this.operationTail = result.then(() => {}, () => {})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const sameSessionIds = (left: readonly SessionId[], right: readonly SessionId[]): boolean =>
|
||||
left.length === right.length && left.every((id, index) => id === right[index])
|
||||
|
||||
export default WorkspaceRegistry
|
||||
|
||||
@@ -19,19 +19,22 @@ 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.
|
||||
* caches before the durable put and mutates only through cached entities).
|
||||
* A delete is valid only for create rollback, after the provisional cache
|
||||
* entry has been removed; deleting a published entity proves a bypass.
|
||||
*/
|
||||
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}' was deleted while the registry cache still `
|
||||
+ 'publishes it — some write path bypassed ctx.workspace',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ctx.workspace.get(WorkspaceId(change.key)) === undefined) {
|
||||
fail(
|
||||
|
||||
@@ -10,6 +10,9 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { WorkspaceId } from './types.ts'
|
||||
|
||||
/** Workspace id schema at the durable boundary; branding has no runtime representation. */
|
||||
const workspaceId = z.string().transform(value => value as WorkspaceId)
|
||||
|
||||
/**
|
||||
* Durable shape of one workspace record. `path` is the `fs.realpath` canon
|
||||
* stamped at create; `sessionIds` is the ordered ownership account (array
|
||||
@@ -26,14 +29,31 @@ export const workspaceRecord = z.object({
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* Durable registry state. `initialized` distinguishes a valid empty registry
|
||||
* from one that still needs the header-only history bootstrap;
|
||||
* `workspaceIds` is the authoritative display order.
|
||||
*/
|
||||
export const workspaceDomainState = z.object({
|
||||
initialized: z.boolean(),
|
||||
workspaceIds: z.array(workspaceId),
|
||||
})
|
||||
|
||||
/** Durable registry state inferred from {@link workspaceDomainState}. */
|
||||
export type WorkspaceDomainState = z.infer<typeof workspaceDomainState>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* {@link WorkspaceId} plus the bootstrap/order singleton. The registry opens
|
||||
* this through `ctx.storage.domain`; the spec object is the single source of
|
||||
* the domain's identity, version, and schemas.
|
||||
*/
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace',
|
||||
version: 1,
|
||||
version: 2,
|
||||
global: {
|
||||
schema: workspaceDomainState,
|
||||
initial: { initialized: false, workspaceIds: [] },
|
||||
},
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
@@ -16,9 +16,9 @@ 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.
|
||||
* an ordered candidate account of sessions. Membership requires both an id in
|
||||
* that account and a session header whose canonical cwd equals the workspace
|
||||
* path. Consumers only see this interface; the implementation stays private.
|
||||
*/
|
||||
export interface Workspace {
|
||||
/** Stable record id (generated uuid). */
|
||||
@@ -34,13 +34,17 @@ export interface Workspace {
|
||||
/** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
|
||||
readonly title: string
|
||||
|
||||
/** ISO-8601 creation instant, stamped at create and never rewritten. */
|
||||
readonly createdAt: string
|
||||
|
||||
/** ISO-8601 instant of the last durable mutation (create counts as one). */
|
||||
readonly updatedAt: 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.
|
||||
* Header-validated sessions in newest-first display order. The durable
|
||||
* candidate account is filtered synchronously: missing headers, invalid
|
||||
* cwd values, and canonical cwd mismatches are never returned. A subsequent
|
||||
* workspace mutation prunes those filtered candidates durably.
|
||||
*/
|
||||
readonly sessionIds: readonly SessionId[]
|
||||
|
||||
@@ -52,16 +56,12 @@ export interface Workspace {
|
||||
setTitle(title: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Record a session under this workspace. Idempotent: a session already on
|
||||
* the account resolves without writing (membership is decided on the
|
||||
* domain write chain, so unawaited concurrent attach/detach calls settle
|
||||
* in call order). For a session not yet on the account, its 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).
|
||||
* Prepend a session to this workspace's candidate account. An already
|
||||
* accounted id resolves without writing; activity-driven reordering uses
|
||||
* `WorkspaceRegistry.touchSession` instead. A new id's live or persisted
|
||||
* header cwd must resolve to an existing directory equal to {@link path};
|
||||
* unknown ids, missing or invalid cwd values, and mismatches reject without
|
||||
* writing.
|
||||
* @param sessionId - The session to record.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
|
||||
@@ -43,10 +43,15 @@ describe('workspace cache/table invariant', () => {
|
||||
expect(() => { ctx.emit('domain/changed', put({ table: 'other', key: 'missing' })) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails a deleted operation — this phase exposes no delete entry point', async () => {
|
||||
it('fails deletion while the registry still publishes the entity', async () => {
|
||||
const ctx = await setup(['w1'])
|
||||
expect(() => { ctx.emit('domain/changed', deleted()) })
|
||||
.toThrow(/no delete entry point/)
|
||||
.toThrow(/cache still publishes/)
|
||||
})
|
||||
|
||||
it('allows deletion only after a provisional create cache entry was removed for rollback', async () => {
|
||||
const ctx = await setup([])
|
||||
expect(() => { ctx.emit('domain/changed', deleted()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails a put whose record the registry cache does not hold', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
@@ -7,103 +7,158 @@ import Storage from '@deepseek-ai/dsh-storage'
|
||||
import type { StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId } from '../src/index.ts'
|
||||
import type { WorkspaceRecord } from '../src/index.ts'
|
||||
import { WorkspaceEntity } from '../src/entity.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const header = (id: string, cwd?: string): SessionHeader =>
|
||||
({ version: 0, id: SessionId(id), createdAt: 0, ...(cwd === undefined ? {} : { cwd }) })
|
||||
const DOMAIN_VERSION = 2
|
||||
|
||||
/**
|
||||
* 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?: {
|
||||
const header = (id: string, cwd?: string, createdAt = 0): SessionHeader => ({
|
||||
version: 0,
|
||||
id: SessionId(id),
|
||||
createdAt,
|
||||
...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
|
||||
interface HarnessOptions {
|
||||
pool?: MemoryMediaPool
|
||||
sessions?: SessionHeader[] | 'absent'
|
||||
sessions?: SessionHeader[]
|
||||
liveSessions?: SessionHeader[]
|
||||
sessionStore?: boolean
|
||||
backend?: StorageBackend
|
||||
}) {
|
||||
}
|
||||
|
||||
/** Boot the real storage/domain/registry composition over controllable header-only peers. */
|
||||
async function harness(options: HarnessOptions = {}) {
|
||||
const pool = options.pool ?? new MemoryMediaPool()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', options?.backend ?? new MemoryStorageBackend(options?.pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
let listed = options?.sessions === 'absent' ? undefined : options?.sessions ?? []
|
||||
if (listed !== undefined) {
|
||||
ctx.provide('sessionPersistence', { list: async () => listed ?? [] })
|
||||
ctx.storage.backend.register('memory', options.backend ?? new MemoryStorageBackend(pool))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
|
||||
let listed = options.sessions ?? []
|
||||
const list = vi.fn(async () => listed)
|
||||
const load = vi.fn(() => { throw new Error('event bodies must not be loaded') })
|
||||
const inspect = vi.fn(() => { throw new Error('event bodies must not be inspected') })
|
||||
ctx.provide('sessionPersistence', { list, load, inspect } as never)
|
||||
|
||||
if (options.sessionStore === true) {
|
||||
await ctx.plugin(SessionStore)
|
||||
} else if (options.liveSessions !== undefined) {
|
||||
const live = new Map(options.liveSessions.map(meta => [meta.id, { header: meta }]))
|
||||
ctx.provide('sessions', {
|
||||
get: (id: SessionId) => live.get(id),
|
||||
list: () => [...live.values()],
|
||||
} as never)
|
||||
}
|
||||
|
||||
const changes: DomainChanged[] = []
|
||||
ctx.on('domain/changed', (change) => { changes.push(change) })
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
const fiber = await ctx.plugin(WorkspaceRegistry)
|
||||
const initChanges = [...changes]
|
||||
changes.length = 0
|
||||
return {
|
||||
ctx,
|
||||
fiber,
|
||||
pool,
|
||||
registry: ctx.workspace,
|
||||
changes,
|
||||
initChanges,
|
||||
list,
|
||||
load,
|
||||
inspect,
|
||||
setSessions: (headers: SessionHeader[]) => { listed = headers },
|
||||
}
|
||||
}
|
||||
|
||||
/** A memory backend whose next `putRecord` throws once when armed, for write-failure paths. */
|
||||
function failingBackend(): { backend: StorageBackend; arm: () => void } {
|
||||
const inner = new MemoryStorageBackend()
|
||||
let failNext = false
|
||||
/** Boot only the storage side, for dependency-pending and startup-failure cases. */
|
||||
async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = new MemoryStorageBackend(pool)) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', backend)
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Backend wrapper that injects one selected bootstrap write failure. */
|
||||
function selectiveFailureBackend(
|
||||
pool: MemoryMediaPool,
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
|
||||
): StorageBackend {
|
||||
const inner = new MemoryStorageBackend(pool)
|
||||
let puts = 0
|
||||
let deletes = 0
|
||||
let globals = 0
|
||||
return {
|
||||
arm: () => { failNext = true },
|
||||
backend: {
|
||||
kv: {
|
||||
open: async (descriptor) => {
|
||||
const unit = await inner.kv.open(descriptor)
|
||||
return {
|
||||
loadAll: () => unit.loadAll(),
|
||||
putRecord: async (table, key, value) => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error('medium write failed (injected)')
|
||||
}
|
||||
return unit.putRecord(table, key, value)
|
||||
},
|
||||
deleteRecord: (table, key) => unit.deleteRecord(table, key),
|
||||
setGlobal: value => unit.setGlobal(value),
|
||||
close: () => unit.close(),
|
||||
}
|
||||
},
|
||||
kv: {
|
||||
open: async (descriptor) => {
|
||||
const unit = await inner.kv.open(descriptor)
|
||||
return {
|
||||
loadAll: () => unit.loadAll(),
|
||||
putRecord: async (table, key, value) => {
|
||||
puts += 1
|
||||
if (puts === failure.putAt) throw new Error('selected bootstrap put failure')
|
||||
await unit.putRecord(table, key, value)
|
||||
},
|
||||
deleteRecord: async (table, key) => {
|
||||
deletes += 1
|
||||
if (deletes === failure.deleteAt) throw new Error('selected rollback delete failure')
|
||||
await unit.deleteRecord(table, key)
|
||||
},
|
||||
setGlobal: async (value) => {
|
||||
globals += 1
|
||||
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
|
||||
await unit.setGlobal(value)
|
||||
},
|
||||
close: () => unit.close(),
|
||||
}
|
||||
},
|
||||
close: () => inner.close(),
|
||||
},
|
||||
close: () => inner.close(),
|
||||
}
|
||||
}
|
||||
|
||||
/** A pool pre-stamped with one stored workspace record, simulating a prior run. */
|
||||
function pooledRecord(id: string, record: WorkspaceRecord): MemoryMediaPool {
|
||||
function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:00:00.000Z'): WorkspaceRecord {
|
||||
return {
|
||||
path,
|
||||
title: basename(path),
|
||||
sessionIds: sessionIds.map(SessionId),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function storedPool(
|
||||
entries: Array<[string, WorkspaceRecord]>,
|
||||
state: WorkspaceDomainState,
|
||||
): MemoryMediaPool {
|
||||
const pool = new MemoryMediaPool()
|
||||
pool.versions.set('workspace', 1)
|
||||
pool.versions.set('workspace', DOMAIN_VERSION)
|
||||
pool.media.set('workspace', {
|
||||
tables: new Map([['workspaces', new Map<string, unknown>([[id, record]])]]),
|
||||
global: null,
|
||||
tables: new Map([['workspaces', new Map<string, unknown>(entries)]]),
|
||||
global: state,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
function storedState(pool: MemoryMediaPool): WorkspaceDomainState {
|
||||
return pool.media.get('workspace')!.global as WorkspaceDomainState
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -117,265 +172,540 @@ afterEach(async () => {
|
||||
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')
|
||||
describe('WorkspaceRegistry lifecycle and bootstrap', () => {
|
||||
it('stays pending without sessionPersistence and never opens or marks the domain', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const ctx = await storageContext(pool)
|
||||
const fiber = await ctx.plugin(WorkspaceRegistry)
|
||||
expect(ctx.get('workspace')).toBeUndefined()
|
||||
expect(pool.media.has('workspace')).toBe(false)
|
||||
|
||||
const list = vi.fn(async () => [] as SessionHeader[])
|
||||
ctx.provide('sessionPersistence', { list } as never)
|
||||
await fiber.await()
|
||||
expect(ctx.workspace.list()).toEqual([])
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
})
|
||||
|
||||
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('bootstraps once from list headers only, in workspace/session createdAt order', async () => {
|
||||
const older = await makeDir('older')
|
||||
const newer = await makeDir('newer')
|
||||
const alias = join(base, 'older-link')
|
||||
const plain = join(base, 'plain.txt')
|
||||
await symlink(older, alias)
|
||||
await writeFile(plain, 'not a directory')
|
||||
const missing = join(base, 'missing')
|
||||
const result = await harness({
|
||||
sessions: [
|
||||
header('older-first', older, 100),
|
||||
header('newer-only', newer, 500),
|
||||
header('older-latest', alias, 300),
|
||||
header('no-cwd', undefined, 900),
|
||||
header('missing-dir', missing, 800),
|
||||
header('plain-file', plain, 700),
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(result.load).not.toHaveBeenCalled()
|
||||
expect(result.inspect).not.toHaveBeenCalled()
|
||||
expect(result.registry.list().map(workspace => workspace.path)).toEqual([newer, older])
|
||||
expect(result.registry.list().map(workspace => workspace.sessionIds)).toEqual([
|
||||
['newer-only'],
|
||||
['older-latest', 'older-first'],
|
||||
])
|
||||
expect(storedState(result.pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: result.registry.list().map(workspace => workspace.id),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a path resolving to a plain file', async () => {
|
||||
const dir = await makeDir('has-file')
|
||||
const file = join(dir, 'plain.txt')
|
||||
await writeFile(file, 'not a directory')
|
||||
const { registry } = await harness()
|
||||
await expect(registry.create(file)).rejects.toThrow(/not a directory/)
|
||||
expect(registry.list()).toEqual([])
|
||||
it('breaks equal bootstrap timestamps by session id and canonical path', async () => {
|
||||
const first = await makeDir('tie-first')
|
||||
const second = await makeDir('tie-second')
|
||||
const result = await harness({
|
||||
sessions: [
|
||||
header('z-session', first, 100),
|
||||
header('a-session', first, 100),
|
||||
header('second-session', second, 100),
|
||||
],
|
||||
})
|
||||
expect(new Set(result.registry.list().map(workspace => workspace.path))).toEqual(new Set([first, second]))
|
||||
expect(result.registry.list().find(workspace => workspace.path === first)!.sessionIds)
|
||||
.toEqual(['a-session', 'z-session'])
|
||||
})
|
||||
|
||||
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('does not rerun bootstrap for a genuinely initialized empty registry', async () => {
|
||||
const late = await makeDir('late-cwd-only')
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({ pool, sessions: [] })
|
||||
expect(first.list).toHaveBeenCalledTimes(1)
|
||||
await first.fiber.dispose()
|
||||
|
||||
const second = await harness({ pool, sessions: [header('late', late, 100)] })
|
||||
expect(second.list).not.toHaveBeenCalled()
|
||||
expect(second.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
})
|
||||
|
||||
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()
|
||||
it('reuses partial records after a bootstrap record write fails', async () => {
|
||||
const firstDir = await makeDir('partial-first')
|
||||
const secondDir = await makeDir('partial-second')
|
||||
const sessions = [header('first', firstDir, 200), header('second', secondDir, 100)]
|
||||
const pool = new MemoryMediaPool()
|
||||
await expect(harness({
|
||||
pool,
|
||||
sessions,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 2 }),
|
||||
})).rejects.toThrow(/selected bootstrap put failure/)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
expect(pool.media.get('workspace')!.global).toBeNull()
|
||||
|
||||
const retried = await harness({ pool, sessions })
|
||||
expect(retried.registry.list()).toHaveLength(2)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(2)
|
||||
expect(storedState(pool).initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('rolls the entity cache back when the durable write fails, leaving the path free to retry', async () => {
|
||||
const dir = await makeDir('rollback')
|
||||
const { backend, arm } = failingBackend()
|
||||
const { registry } = await harness({ backend })
|
||||
arm()
|
||||
await expect(registry.create(dir)).rejects.toThrow(/injected/)
|
||||
expect(registry.list()).toEqual([])
|
||||
const retried = await registry.create(dir)
|
||||
expect(retried.path).toBe(dir)
|
||||
it('reuses durable order when the final initialized marker write fails', async () => {
|
||||
const dir = await makeDir('marker-retry')
|
||||
const sessions = [header('session', dir, 100)]
|
||||
const pool = new MemoryMediaPool()
|
||||
await expect(harness({
|
||||
pool,
|
||||
sessions,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})).rejects.toThrow(/selected bootstrap marker failure/)
|
||||
expect(storedState(pool)).toMatchObject({ initialized: false })
|
||||
expect(storedState(pool).workspaceIds).toHaveLength(1)
|
||||
|
||||
const retried = await harness({ pool, sessions })
|
||||
expect(retried.registry.list()).toHaveLength(1)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
expect(storedState(pool).initialized).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects any table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const ctx = new Context()
|
||||
// Constructed directly, Service.init never ran: no domain, no table.
|
||||
const registry = new WorkspaceRegistry(ctx)
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
it('merges partial records and leaves an already-accounted cwd drift ungrouped', async () => {
|
||||
const owned = await makeDir('partial-owned')
|
||||
const prior = await makeDir('partial-prior')
|
||||
const drifted = await makeDir('partial-drifted')
|
||||
const ownedId = WorkspaceId('00000000-0000-4000-8000-000000000010')
|
||||
const priorId = WorkspaceId('00000000-0000-4000-8000-000000000011')
|
||||
const pool = storedPool(
|
||||
[
|
||||
[ownedId, record(owned, ['old'], '2026-07-24T00:00:00.000Z')],
|
||||
[priorId, record(prior, ['drift'], '2026-07-23T00:00:00.000Z')],
|
||||
],
|
||||
{ initialized: false, workspaceIds: [] },
|
||||
)
|
||||
const result = await harness({
|
||||
pool,
|
||||
sessions: [header('new', owned, 200), header('old', owned, 100), header('drift', drifted, 300)],
|
||||
})
|
||||
expect(result.registry.list().map(workspace => workspace.id)).toContain(ownedId)
|
||||
expect(result.registry.get(ownedId)!.sessionIds).toEqual(['new', 'old'])
|
||||
expect(result.registry.list().some(workspace => workspace.path === drifted)).toBe(false)
|
||||
})
|
||||
|
||||
it('closes its domain on fiber disposal so a re-plugged registry reopens it', async () => {
|
||||
it('orders headerless partial records by prior order, then stable id', async () => {
|
||||
const first = await makeDir('fallback-first')
|
||||
const second = await makeDir('fallback-second')
|
||||
const firstId = WorkspaceId('00000000-0000-4000-8000-000000000020')
|
||||
const secondId = WorkspaceId('00000000-0000-4000-8000-000000000021')
|
||||
const entries: Array<[string, WorkspaceRecord]> = [
|
||||
[secondId, record(second, [], '2026-07-24T00:00:00.000Z')],
|
||||
[firstId, record(first, [], '2026-07-24T00:00:00.000Z')],
|
||||
]
|
||||
const prior = await harness({
|
||||
pool: storedPool(entries, { initialized: false, workspaceIds: [secondId, firstId] }),
|
||||
})
|
||||
expect(prior.registry.list().map(workspace => workspace.id)).toEqual([secondId, firstId])
|
||||
|
||||
const byId = await harness({
|
||||
pool: storedPool(entries, { initialized: false, workspaceIds: [] }),
|
||||
})
|
||||
expect(byId.registry.list().map(workspace => workspace.id)).toEqual([firstId, secondId])
|
||||
})
|
||||
|
||||
it('closes its domain on disposal and reloads the persisted stable order', async () => {
|
||||
const dir = await makeDir('replug')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
const fiber = ctx.plugin(WorkspaceRegistry)
|
||||
await fiber
|
||||
const first = await ctx.workspace.create(dir)
|
||||
await fiber.dispose()
|
||||
// The registry's effect closed the domain, freeing the name: a second
|
||||
// plugin of the same registry must reopen it (not already-open) and see
|
||||
// the durable record.
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
const reloaded = await ctx.workspace.resolveByPath(dir)
|
||||
expect(reloaded?.id).toBe(first.id)
|
||||
const result = await harness()
|
||||
const first = await result.registry.create(dir)
|
||||
await result.fiber.dispose()
|
||||
const nextFiber = await result.ctx.plugin(WorkspaceRegistry)
|
||||
expect(result.ctx.workspace.list().map(workspace => workspace.id)).toEqual([first.id])
|
||||
await nextFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
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'])
|
||||
describe('WorkspaceRegistry create and lookup', () => {
|
||||
it('creates newest-first and idempotently reuses a canonical path without retitling', async () => {
|
||||
const firstDir = await makeDir('first')
|
||||
const secondDir = await makeDir('second')
|
||||
const alias = join(base, 'first-link')
|
||||
await symlink(firstDir, alias)
|
||||
const { registry, pool } = await harness()
|
||||
const first = await registry.create(firstDir, 'Original')
|
||||
const second = await registry.create(secondDir)
|
||||
const reused = await registry.create(alias, 'Ignored')
|
||||
expect(reused).toBe(first)
|
||||
expect(first.title).toBe('Original')
|
||||
expect(registry.list()).toEqual([second, first])
|
||||
expect(storedState(pool).workspaceIds).toEqual([second.id, first.id])
|
||||
expect(await registry.resolveByPath(alias)).toBe(first)
|
||||
expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a cwd resolving elsewhere, a missing cwd, and an unknown session', async () => {
|
||||
it('serializes concurrent same-path creates into one entity', async () => {
|
||||
const dir = await makeDir('concurrent')
|
||||
const { registry, pool } = await harness()
|
||||
const [left, right] = await Promise.all([
|
||||
registry.create(dir, 'Winner'),
|
||||
registry.create(dir, 'Loser'),
|
||||
])
|
||||
expect(left).toBe(right)
|
||||
expect(registry.list()).toEqual([left])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a duplicate display name on a different canonical path', async () => {
|
||||
const firstDir = await makeDir('named-first')
|
||||
const secondDir = await makeDir('named-second')
|
||||
const { registry } = await harness()
|
||||
await registry.create(firstDir, 'Shared')
|
||||
await expect(registry.create(secondDir, 'Shared')).rejects.toEqual(
|
||||
expect.objectContaining<Partial<WorkspaceNameConflictError>>({
|
||||
workspaceName: 'Shared',
|
||||
}),
|
||||
)
|
||||
expect(registry.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects nonexistent and non-directory paths without changing order', async () => {
|
||||
const parent = await makeDir('invalid')
|
||||
const file = join(parent, 'plain.txt')
|
||||
await writeFile(file, 'file')
|
||||
const { registry } = await harness()
|
||||
await expect(registry.create(join(parent, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(registry.create(file)).rejects.toThrow(/not a directory/)
|
||||
await expect(registry.resolveByPath(join(parent, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(registry.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back the provisional cache when the record write fails', async () => {
|
||||
const dir = await makeDir('write-failure')
|
||||
const result = await harness()
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(await result.registry.create(dir)).toBeDefined()
|
||||
})
|
||||
|
||||
it('rolls back a record when registry-order persistence fails', async () => {
|
||||
const dir = await makeDir('order-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(0)
|
||||
})
|
||||
|
||||
it('reports both order and rollback failures while retaining the recoverable record', async () => {
|
||||
const dir = await makeDir('rollback-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const registry = new WorkspaceRegistry(new Context())
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
expect(() => registry.list()).toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
const result = await harness()
|
||||
result.setSessions([
|
||||
header('s1', dir, 1),
|
||||
header('s2', dir, 2),
|
||||
header('ungrouped', dir, 3),
|
||||
])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
|
||||
const beforeTouch = result.changes.length
|
||||
await Promise.all([
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2'])
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('s1'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('ungrouped'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2'])
|
||||
})
|
||||
|
||||
it('does not resurrect a session detached before its queued touch', async () => {
|
||||
const dir = await makeDir('detach-touch-race')
|
||||
const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await Promise.all([
|
||||
workspace.detachSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
const written = result.changes.length
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s2'])
|
||||
})
|
||||
|
||||
it('does not reinsert a candidate absent at the durable touch slot', async () => {
|
||||
const dir = await makeDir('stale-touch')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000030')
|
||||
let durable = record(dir, ['s2', 's1'])
|
||||
const table = {
|
||||
update: async (
|
||||
_id: WorkspaceId,
|
||||
update: (current: WorkspaceRecord) => WorkspaceRecord,
|
||||
): Promise<WorkspaceRecord> => {
|
||||
durable = { ...durable, sessionIds: [SessionId('s2')] }
|
||||
durable = update(durable)
|
||||
return durable
|
||||
},
|
||||
}
|
||||
const entity = new WorkspaceEntity({
|
||||
table: () => table as never,
|
||||
sessionPath: () => dir,
|
||||
readSessionHeader: async () => header('s1', dir),
|
||||
rememberSessionPath: () => {},
|
||||
}, id, record(dir, ['s2', 's1']))
|
||||
await entity.touchSession(SessionId('s1'))
|
||||
expect(durable.sessionIds).toEqual(['s2'])
|
||||
})
|
||||
|
||||
it('validates a lazy live session without requiring it in persistence.list()', async () => {
|
||||
const dir = await makeDir('live')
|
||||
const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('live'))
|
||||
expect(workspace.sessionIds).toEqual(['live'])
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects mismatched, missing, unresolved, non-directory, and unknown cwd facts', 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/)
|
||||
const gone = await makeDir('gone')
|
||||
const file = join(base, 'cwd-file')
|
||||
await writeFile(file, 'file')
|
||||
const result = await harness()
|
||||
result.setSessions([
|
||||
header('mismatch', elsewhere),
|
||||
header('no-cwd'),
|
||||
header('gone', gone),
|
||||
header('file', file),
|
||||
])
|
||||
await rm(gone, { recursive: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await expect(workspace.attachSession(SessionId('mismatch'))).rejects.toThrow(/resolves to/)
|
||||
await expect(workspace.attachSession(SessionId('no-cwd'))).rejects.toThrow(/no cwd/)
|
||||
await expect(workspace.attachSession(SessionId('gone'))).rejects.toThrow(/does not resolve/)
|
||||
await expect(workspace.attachSession(SessionId('file'))).rejects.toThrow(/not a directory/)
|
||||
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)
|
||||
})
|
||||
|
||||
it('decides membership at the write-chain slot: unawaited detach then attach re-attaches', async () => {
|
||||
it('decides detach/attach membership at domain write-chain slots', async () => {
|
||||
const dir = await makeDir('race')
|
||||
const { registry } = await harness({ sessions: [header('s1', dir)] })
|
||||
const workspace = await registry.create(dir)
|
||||
const result = await harness({ sessions: [header('s1', dir)] })
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
// Both fire before either lands. Snapshot-based idempotence would see
|
||||
// 's1' still on the account and turn the attach into a no-op, losing it;
|
||||
// chain-slot decisions replay detach → attach in order. (The attach skips
|
||||
// re-validation off the same stale snapshot — the cwd fact is immutable —
|
||||
// and enqueues immediately, keeping the chain order deterministic here.)
|
||||
const detached = workspace.detachSession(SessionId('s1'))
|
||||
const attached = workspace.attachSession(SessionId('s1'))
|
||||
await Promise.all([detached, attached])
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('keeps workspace order stable while touch order survives reload', async () => {
|
||||
const older = await makeDir('stable-older')
|
||||
const newer = await makeDir('stable-newer')
|
||||
const sessions = [
|
||||
header('old-1', older, 100),
|
||||
header('old-2', older, 200),
|
||||
header('new-1', newer, 300),
|
||||
]
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({ pool, sessions })
|
||||
const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id)
|
||||
const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)!
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1'])
|
||||
await first.registry.touchSession(SessionId('old-1'))
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2'])
|
||||
expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
await first.fiber.dispose()
|
||||
|
||||
const reloaded = await harness({ pool, sessions })
|
||||
expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds)
|
||||
.toEqual(['old-1', 'old-2'])
|
||||
})
|
||||
|
||||
it('persists activity order from session/event without any stream consumer', async () => {
|
||||
const dir = await makeDir('event-touch')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('event-first'))
|
||||
await workspace.attachSession(SessionId('event-second'))
|
||||
expect(workspace.sessionIds).toEqual(['event-second', 'event-first'])
|
||||
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) })
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds)
|
||||
.toEqual(['event-first', 'event-second'])
|
||||
})
|
||||
|
||||
it('contains a background activity write failure at the service listener', async () => {
|
||||
const dir = await makeDir('event-touch-failure')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('failed-first'))
|
||||
await workspace.attachSession(SessionId('failed-second'))
|
||||
const warn = vi.spyOn(result.ctx.logger, 'warn')
|
||||
result.pool.failNextWrites = 1
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) })
|
||||
expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('consistency projections', () => {
|
||||
it('filters accounted ids with no stored session and prunes them on the next mutation', async () => {
|
||||
const dir = await makeDir('stale')
|
||||
describe('header-validated membership projection', () => {
|
||||
it('requires both candidate id and matching canonical cwd without re-reading on list()', async () => {
|
||||
const owned = await makeDir('owned')
|
||||
const elsewhere = await makeDir('projection-elsewhere')
|
||||
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')
|
||||
const pool = storedPool(
|
||||
[[id, record(owned, ['good', 'mismatch', 'missing'])]],
|
||||
{ initialized: true, workspaceIds: [id] },
|
||||
)
|
||||
const result = await harness({
|
||||
pool,
|
||||
sessions: [
|
||||
header('good', owned),
|
||||
header('mismatch', elsewhere),
|
||||
header('cwd-only', owned),
|
||||
],
|
||||
})
|
||||
const workspace = result.registry.list()[0]!
|
||||
expect(workspace.sessionIds).toEqual(['good'])
|
||||
expect(result.registry.list()[0]!.sessionIds).toEqual(['good'])
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['good', 'mismatch', 'missing'])
|
||||
|
||||
await workspace.setTitle('pruned')
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['good'])
|
||||
expect(workspace.sessionIds).not.toContain('cwd-only')
|
||||
})
|
||||
|
||||
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' })
|
||||
const workspace = registry.get(id)!
|
||||
expect(workspace.sessionIds).toEqual(['maybe'])
|
||||
// Mutations must not prune either: unverifiable membership is kept as-is.
|
||||
await workspace.setTitle('still-unverified')
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['maybe'])
|
||||
it('rejects duplicate candidate ownership, duplicate paths, and initialized order drift', async () => {
|
||||
const first = await makeDir('corrupt-first')
|
||||
const second = await makeDir('corrupt-second')
|
||||
const firstId = '00000000-0000-4000-8000-000000000002'
|
||||
const secondId = '00000000-0000-4000-8000-000000000003'
|
||||
const duplicateSession = storedPool(
|
||||
[[firstId, record(first, ['dup'])], [secondId, record(second, ['dup'])]],
|
||||
{ initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(secondId)] },
|
||||
)
|
||||
await expect(harness({ pool: duplicateSession })).rejects.toThrow(/accounted/)
|
||||
|
||||
const duplicatePath = storedPool(
|
||||
[[firstId, record(first, [])], [secondId, record(first, [])]],
|
||||
{ initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(secondId)] },
|
||||
)
|
||||
await expect(harness({ pool: duplicatePath })).rejects.toThrow(/claimed/)
|
||||
|
||||
const orphan = storedPool(
|
||||
[[firstId, record(first, [])], [secondId, record(second, [])]],
|
||||
{ initialized: true, workspaceIds: [WorkspaceId(firstId)] },
|
||||
)
|
||||
await expect(harness({ pool: orphan })).rejects.toThrow(/absent from registry order/)
|
||||
|
||||
const repeated = storedPool(
|
||||
[[firstId, record(first, [])]],
|
||||
{ initialized: true, workspaceIds: [WorkspaceId(firstId), WorkspaceId(firstId)] },
|
||||
)
|
||||
await expect(harness({ pool: repeated })).rejects.toThrow(/repeats workspace/)
|
||||
|
||||
const missing = storedPool(
|
||||
[],
|
||||
{ initialized: true, workspaceIds: [WorkspaceId(firstId)] },
|
||||
)
|
||||
await expect(harness({ pool: missing })).rejects.toThrow(/references missing workspace/)
|
||||
})
|
||||
|
||||
it('prunes dead ids even when the triggering mutation is itself a no-op', async () => {
|
||||
const dir = await makeDir('prune-on-noop')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000007')
|
||||
const pool = pooledRecord(id, record(dir, ['ghost']))
|
||||
const { registry, changes } = await harness({ pool, sessions: [] })
|
||||
const workspace = registry.get(id)!
|
||||
// Detaching an id that was never on the account changes nothing by
|
||||
// itself, but the mutation slot still prunes the dead 'ghost' durably.
|
||||
await workspace.detachSession(SessionId('never-there'))
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual([])
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects startup over a medium accounting one session twice', async () => {
|
||||
const dirA = await makeDir('double-a')
|
||||
const dirB = await makeDir('double-b')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dirA, ['dup']))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000004', record(dirB, ['dup']))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
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/)
|
||||
})
|
||||
|
||||
it('rejects startup over a medium where two records claim one path', async () => {
|
||||
const dirA = await makeDir('claimed')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000005', record(dirA, []))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000006', record(dirA, []))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
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(/claimed/)
|
||||
it('fails list if the durable order and entity cache are externally diverged', async () => {
|
||||
const dir = await makeDir('cache-diverged')
|
||||
const result = await harness()
|
||||
const workspace = await result.registry.create(dir)
|
||||
const internals = result.registry as unknown as { entities: Map<WorkspaceId, unknown> }
|
||||
internals.entities.delete(workspace.id)
|
||||
expect(() => result.registry.list()).toThrow(/references missing workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace mutation failures', () => {
|
||||
it('propagates a medium write failure from a mutation and keeps the old snapshot', async () => {
|
||||
const dir = await makeDir('write-fail')
|
||||
const { backend, arm } = failingBackend()
|
||||
const { registry } = await harness({ backend })
|
||||
const workspace = await registry.create(dir)
|
||||
arm()
|
||||
await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/)
|
||||
expect(workspace.title).toBe('write-fail')
|
||||
describe('workspace mutation and status', () => {
|
||||
it('keeps createdAt stable, advances updatedAt, and preserves snapshot on write failure', async () => {
|
||||
const dir = await makeDir('timestamps')
|
||||
const result = await harness()
|
||||
const workspace = await result.registry.create(dir)
|
||||
const createdAt = workspace.createdAt
|
||||
expect(workspace.updatedAt).toBe(createdAt)
|
||||
await workspace.setTitle('kept')
|
||||
expect(workspace.createdAt).toBe(createdAt)
|
||||
expect(Date.parse(workspace.updatedAt)).toBeGreaterThanOrEqual(Date.parse(createdAt))
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/)
|
||||
expect(workspace.title).toBe('kept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace.status', () => {
|
||||
it('reports ok while the directory exists and missing-dir once it is gone, without mutating the record', async () => {
|
||||
it('reports directory disappearance without mutating the workspace', 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)
|
||||
// The path re-materializing as a non-directory is still missing-dir.
|
||||
await writeFile(dir, 'now a file')
|
||||
expect(await workspace.status()).toBe('missing-dir')
|
||||
expect(registry.get(workspace.id)).toBe(workspace)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user