feat(workspace): persistent workspace entity over the domain form
ctx.workspace registry owns WorkspaceId-branded records: realpath- normalized unique paths (create rejects collisions; resolveByPath shares the normalization), ordered sessionIds as the single source of ownership truth, attachSession gated on the session header cwd matching the workspace path (double-booking structurally impossible), dead session ids filtered on projection and pruned on the next mutate, status() reporting missing directories. No delete surface this phase — deletion ships together with the session-side primitives as future work.
This commit is contained in:
150
packages/workspace/workspace/src/entity.ts
Normal file
150
packages/workspace/workspace/src/entity.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Package-private workspace entity: the single {@link Workspace}
|
||||
* implementation. Holds a record snapshot that is swapped in place after each
|
||||
* durable mutation; every write funnels through the private `mutate` so
|
||||
* `updatedAt` stamping and dead-account pruning happen exactly once.
|
||||
* Not re-exported from the package entrypoint — consumers see only the
|
||||
* `Workspace` interface.
|
||||
* @module @deepseek-ai/dsh-workspace/src/entity
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-domain'
|
||||
import type { WorkspaceRecord } from './spec.ts'
|
||||
import type { Workspace, WorkspaceId } from './types.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
|
||||
/**
|
||||
* The registry-owned machinery an entity mutates through. Entities never see
|
||||
* the registry itself — only the open table, the known-session view backing
|
||||
* the `sessionIds` projection, and header reads for attach validation.
|
||||
*/
|
||||
export interface WorkspaceEntityHost {
|
||||
/**
|
||||
* Resolve the open `workspaces` table.
|
||||
* @returns the table; throws while the registry has not started yet.
|
||||
*/
|
||||
table(): KvTable<WorkspaceId, WorkspaceRecord>
|
||||
|
||||
/**
|
||||
* Synchronous view of the session ids known to exist in session
|
||||
* persistence.
|
||||
* @returns the id set, or `undefined` when persistence has been absent so
|
||||
* far (membership cannot be verified, so projections serve the account
|
||||
* unfiltered).
|
||||
*/
|
||||
knownSessionIds(): ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Read one stored session header for attach validation.
|
||||
* @param id - The session whose header to read.
|
||||
* @returns the header; rejects when session persistence is absent or holds
|
||||
* no session with this id.
|
||||
*/
|
||||
readSessionHeader(id: SessionId): Promise<SessionHeader>
|
||||
}
|
||||
|
||||
/** The single {@link Workspace} implementation; constructed only by the registry. */
|
||||
export class WorkspaceEntity implements Workspace {
|
||||
private record: WorkspaceRecord
|
||||
|
||||
/**
|
||||
* @param host - Registry-owned table, known-session view, and header reads.
|
||||
* @param id - The record's stable id.
|
||||
* @param record - The validated record snapshot loaded or just written.
|
||||
*/
|
||||
constructor(
|
||||
private readonly host: WorkspaceEntityHost,
|
||||
readonly id: WorkspaceId,
|
||||
record: WorkspaceRecord,
|
||||
) {
|
||||
this.record = record
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.record.path
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.record.title
|
||||
}
|
||||
|
||||
get sessionIds(): readonly SessionId[] {
|
||||
const known = this.host.knownSessionIds()
|
||||
if (known === undefined) return this.record.sessionIds
|
||||
return this.record.sessionIds.filter(id => known.has(id))
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.mutate(record => ({ ...record, title }))
|
||||
}
|
||||
|
||||
async attachSession(sessionId: SessionId): Promise<void> {
|
||||
if (this.record.sessionIds.includes(sessionId)) return
|
||||
const header = await this.host.readSessionHeader(sessionId)
|
||||
if (header.cwd === undefined) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ 'its stored header carries no cwd to validate against',
|
||||
)
|
||||
}
|
||||
let cwd: string
|
||||
try {
|
||||
cwd = await realpathNormalize(header.cwd)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd '${header.cwd}' does not resolve, so it cannot be validated`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (cwd !== this.record.path) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd resolves to '${cwd}'`,
|
||||
)
|
||||
}
|
||||
await this.mutate(record => record.sessionIds.includes(sessionId)
|
||||
? record
|
||||
: { ...record, sessionIds: [...record.sessionIds, sessionId] })
|
||||
}
|
||||
|
||||
async detachSession(sessionId: SessionId): Promise<void> {
|
||||
if (!this.record.sessionIds.includes(sessionId)) return
|
||||
await this.mutate(record => ({
|
||||
...record,
|
||||
sessionIds: record.sessionIds.filter(id => id !== sessionId),
|
||||
}))
|
||||
}
|
||||
|
||||
async status(): Promise<'ok' | 'missing-dir'> {
|
||||
try {
|
||||
return (await stat(this.record.path)).isDirectory() ? 'ok' : 'missing-dir'
|
||||
} catch {
|
||||
// Any stat failure (ENOENT, dangling parent, permission loss) means the
|
||||
// directory is not usable right now; the record itself never mutates.
|
||||
return 'missing-dir'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single write path: run `fn` on the domain write chain via
|
||||
* `table.update`, stamping `updatedAt` and pruning accounted ids whose
|
||||
* session no longer exists (consistency rule: dead ids are dropped on the
|
||||
* next mutation, whatever that mutation is), then swap the snapshot.
|
||||
*/
|
||||
private async mutate(fn: (record: WorkspaceRecord) => WorkspaceRecord): Promise<void> {
|
||||
const known = this.host.knownSessionIds()
|
||||
this.record = await this.host.table().update(this.id, (current) => {
|
||||
const next = fn(current)
|
||||
return {
|
||||
...next,
|
||||
sessionIds: known === undefined
|
||||
? next.sessionIds
|
||||
: next.sessionIds.filter(id => known.has(id)),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
213
packages/workspace/workspace/src/index.ts
Normal file
213
packages/workspace/workspace/src/index.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Workspace entity registry (`ctx.workspace`): durable workspace records over
|
||||
* the domain data form, with session attachment validated against stored
|
||||
* session headers. This package owns the `WorkspaceId` brand and the
|
||||
* `workspace` domain; consumers see the {@link Workspace} interface only.
|
||||
* @module @deepseek-ai/dsh-workspace
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { basename } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: merges `sessionPersistence` into the Context service map for the
|
||||
// optional `ctx.get` lookups below.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-domain'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceRecord } from './spec.ts'
|
||||
import { WorkspaceEntity } from './entity.ts'
|
||||
import type { WorkspaceEntityHost } from './entity.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
|
||||
|
||||
export type { Workspace } from './types.ts'
|
||||
export { workspaceRecord, workspaceDomainSpec } from './spec.ts'
|
||||
export type { WorkspaceRecord } from './spec.ts'
|
||||
export { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
|
||||
export type WorkspaceId = WorkspaceIdBrand
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link WorkspaceId}.
|
||||
* @param id - the raw workspace id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function WorkspaceId(id: string): WorkspaceId {
|
||||
return id as WorkspaceId
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workspace: WorkspaceRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace registry service. Opens the `workspace` domain at startup,
|
||||
* rebuilds one entity per stored record, and serves entities from an
|
||||
* in-memory cache keyed by id. Session persistence is an OPTIONAL peer
|
||||
* (resolved via `ctx.get`, never injected): while it is absent, session
|
||||
* attachment rejects (what cannot be validated is not recorded) and
|
||||
* `sessionIds` projections serve the account unfiltered.
|
||||
*
|
||||
* There is deliberately no delete entry point in this phase: workspace
|
||||
* deletion ships as one complete semantic together with the session-cascade
|
||||
* primitives (future work in the owning Agent Note).
|
||||
*/
|
||||
export class WorkspaceRegistry extends Service {
|
||||
static inject = ['storage']
|
||||
|
||||
private table?: KvTable<WorkspaceId, WorkspaceRecord>
|
||||
private readonly entities = new Map<WorkspaceId, WorkspaceEntity>()
|
||||
/**
|
||||
* Session ids known to exist in session persistence; `undefined` until the
|
||||
* first successful listing. Refreshed at startup and on every attach
|
||||
* validation — within one process sessions are only ever added (this phase
|
||||
* has no delete primitive), so the set can only lag by missing very recent
|
||||
* sessions, never by holding dead ones from this process's lifetime.
|
||||
*/
|
||||
private known?: Set<string>
|
||||
|
||||
private readonly host: WorkspaceEntityHost = {
|
||||
table: () => this.requireTable(),
|
||||
knownSessionIds: () => this.known,
|
||||
readSessionHeader: id => this.readSessionHeader(id),
|
||||
}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workspace')
|
||||
}
|
||||
|
||||
/** Open the domain and rebuild the entity cache before the service is published as active. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
|
||||
this.table = domain.table('workspaces')
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
this.known = new Set<string>((await persistence.list()).map(header => header.id))
|
||||
}
|
||||
// Rebuild entities, rejecting a double account: one session recorded
|
||||
// under two workspaces means the medium was edited externally (the attach
|
||||
// check makes it structurally impossible to write), and hiding it would
|
||||
// silently pick a winner.
|
||||
const accounted = new Map<string, WorkspaceId>()
|
||||
for (const [id, record] of this.table.entries()) {
|
||||
for (const sessionId of record.sessionIds) {
|
||||
const holder = accounted.get(sessionId)
|
||||
if (holder !== undefined) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: session '${sessionId}' is accounted `
|
||||
+ `by both workspace '${holder}' and workspace '${id}'`,
|
||||
)
|
||||
}
|
||||
accounted.set(sessionId, id)
|
||||
}
|
||||
this.entities.set(id, new WorkspaceEntity(this.host, id, record))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace over an existing directory. The path is canonicalized
|
||||
* through `fs.realpath` first — a nonexistent directory rejects with the
|
||||
* original `ENOENT`, and a canonical path already owned by another
|
||||
* workspace (including a symlink resolving to it) rejects.
|
||||
* @param path - Directory the workspace points at; canonicalized before storing.
|
||||
* @param title - Display title; defaults to `basename` of the canonical path.
|
||||
* @returns the created workspace after durability.
|
||||
*/
|
||||
async create(path: string, title?: string): Promise<Workspace> {
|
||||
const table = this.requireTable()
|
||||
const canonical = await realpathNormalize(path)
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) {
|
||||
throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`)
|
||||
}
|
||||
}
|
||||
const id = WorkspaceId(randomUUID())
|
||||
const now = new Date().toISOString()
|
||||
const record: WorkspaceRecord = {
|
||||
path: canonical,
|
||||
title: title ?? basename(canonical),
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
// Cache before the durable put: a concurrent same-path create fails the
|
||||
// scan above, and the entity already exists when `domain/changed` fires.
|
||||
this.entities.set(id, entity)
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a workspace by id.
|
||||
* @param id - The workspace id.
|
||||
* @returns the workspace, or `undefined` when unknown.
|
||||
*/
|
||||
get(id: WorkspaceId): Workspace | undefined {
|
||||
return this.entities.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all workspaces, in load-then-creation order.
|
||||
* @returns a fresh array of the cached entities.
|
||||
*/
|
||||
list(): Workspace[] {
|
||||
return [...this.entities.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace by directory path, through the same `fs.realpath`
|
||||
* canon as {@link create} (hence async). A path that does not exist rejects
|
||||
* with the original error — a missing directory has no canonical form to
|
||||
* compare (a workspace whose recorded directory vanished is only reachable
|
||||
* by id; see `Workspace.status`).
|
||||
* @param path - Directory path in any spelling (symlinks, `..`, trailing slash).
|
||||
* @returns the owning workspace, or `undefined` when none matches.
|
||||
*/
|
||||
async resolveByPath(path: string): Promise<Workspace | undefined> {
|
||||
const canonical = await realpathNormalize(path)
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) return entity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private requireTable(): KvTable<WorkspaceId, WorkspaceRecord> {
|
||||
if (this.table === undefined) {
|
||||
throw new Error('workspace registry is not started yet')
|
||||
}
|
||||
return this.table
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one stored session header for attach validation, refreshing the
|
||||
* known-session view from the same listing. Rejects when session
|
||||
* persistence is absent or holds no session with this id.
|
||||
*/
|
||||
private async readSessionHeader(id: SessionId): Promise<SessionHeader> {
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error(
|
||||
`cannot validate session '${id}': no session persistence service is available`,
|
||||
)
|
||||
}
|
||||
const headers = await persistence.list()
|
||||
this.known = new Set<string>(headers.map(header => header.id))
|
||||
const header = headers.find(candidate => candidate.id === id)
|
||||
if (header === undefined) {
|
||||
throw new Error(`cannot validate session '${id}': session persistence holds no such session`)
|
||||
}
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkspaceRegistry
|
||||
53
packages/workspace/workspace/src/invariant.ts
Normal file
53
packages/workspace/workspace/src/invariant.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace`.
|
||||
* @module @deepseek-ai/dsh-workspace/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-domain'
|
||||
import { WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workspace-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relationship: the registry's entity cache mirrors the workspace
|
||||
* domain's durable table. Every `domain/changed` for the `workspaces` table
|
||||
* must name a record the cache already holds an entity for (the registry
|
||||
* caches before the durable put and mutates only through cached entities),
|
||||
* and no `deleted` operation may appear at all — this phase ships no delete
|
||||
* entry point, so a deletion proves a write path outside the registry.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign(
|
||||
(ctx: Context, fail: (message: string) => never) => {
|
||||
ctx.on('domain/changed', (change: DomainChanged) => {
|
||||
if (change.domain !== 'workspace' || change.table !== 'workspaces') return
|
||||
if (change.operation === 'deleted') {
|
||||
fail(
|
||||
`workspace record '${change.key}' emitted a deleted change, but the registry `
|
||||
+ 'exposes no delete entry point — some write path bypassed ctx.workspace',
|
||||
)
|
||||
}
|
||||
if (ctx.workspace.get(WorkspaceId(change.key)) === undefined) {
|
||||
fail(
|
||||
`workspace record '${change.key}' landed durably but the registry cache holds `
|
||||
+ 'no entity for it — the cache and the domain table have diverged',
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ inject: ['workspace'] },
|
||||
)
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
22
packages/workspace/workspace/src/paths.ts
Normal file
22
packages/workspace/workspace/src/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Path canonicalization for workspace identity.
|
||||
* @module @deepseek-ai/dsh-workspace/src/paths
|
||||
*/
|
||||
|
||||
import { realpath } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
|
||||
* segments, and symlinks are all resolved. This is the ONE uniqueness canon of
|
||||
* the package — workspace paths are stored canonicalized, uniqueness is
|
||||
* string equality of canonicalized paths (a symlink to an existing
|
||||
* workspace's directory collides), and attach-time session `cwd` checks go
|
||||
* through the same canon. A path that does not exist rejects with the
|
||||
* original `ENOENT` — this is `create`'s reject path (a workspace must point
|
||||
* at an existing directory).
|
||||
* @param path - The path to canonicalize.
|
||||
* @returns the canonical absolute path.
|
||||
*/
|
||||
export async function realpathNormalize(path: string): Promise<string> {
|
||||
return await realpath(path)
|
||||
}
|
||||
39
packages/workspace/workspace/src/spec.ts
Normal file
39
packages/workspace/workspace/src/spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The workspace domain declaration: record schema and the `defineDomain` spec
|
||||
* the registry opens. The zod schema is the durable-boundary validator today
|
||||
* and the direct source of the RPC wire projection in a later phase.
|
||||
* @module @deepseek-ai/dsh-workspace/src/spec
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { defineDomain, domainTable } from '@deepseek-ai/dsh-domain'
|
||||
import type { WorkspaceId } from './types.ts'
|
||||
|
||||
/**
|
||||
* Durable shape of one workspace record. `path` is the `fs.realpath` canon
|
||||
* stamped at create; `sessionIds` is the ordered ownership account (array
|
||||
* order is display order); timestamps are ISO-8601 strings.
|
||||
*/
|
||||
export const workspaceRecord = z.object({
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* The workspace domain spec: one `workspaces` table keyed by
|
||||
* {@link WorkspaceId}, no global singleton. The registry opens this through
|
||||
* `ctx.storage.domain`; the spec object is the single source of the domain's
|
||||
* identity, version, and record schema.
|
||||
*/
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace',
|
||||
version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
84
packages/workspace/workspace/src/types.ts
Normal file
84
packages/workspace/workspace/src/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Public type vocabulary of the workspace entity: the `WorkspaceId` brand and
|
||||
* the `Workspace` consumer interface. Types only — the `WorkspaceId` factory
|
||||
* lives in `index.ts` (this file carries no runtime code).
|
||||
* @module @deepseek-ai/dsh-workspace/src/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Identifies one workspace record. A generated uuid, never the path: path
|
||||
* normalization rewrites paths, and a reference anchor must stay stable.
|
||||
*/
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
|
||||
/**
|
||||
* One workspace: a stable id over an existing directory, a display title, and
|
||||
* the ordered account of sessions that belong to it. The account is the sole
|
||||
* source of ownership — sessions are never inferred from cwd. Consumers only
|
||||
* see this interface; the entity implementation stays package-private.
|
||||
*/
|
||||
export interface Workspace {
|
||||
/** Stable record id (generated uuid). */
|
||||
readonly id: WorkspaceId
|
||||
|
||||
/**
|
||||
* Canonical directory path: the `fs.realpath` of the path given at create
|
||||
* time (trailing slashes, `..`, and symlinks all resolved). Never rewritten
|
||||
* afterwards, even when the directory disappears (see {@link status}).
|
||||
*/
|
||||
readonly path: string
|
||||
|
||||
/** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
|
||||
readonly title: string
|
||||
|
||||
/**
|
||||
* Sessions recorded under this workspace, in attach order (the array order
|
||||
* is the display order). A projection: accounted ids whose session no
|
||||
* longer exists in session persistence are filtered out here (and dropped
|
||||
* from the durable account on the next mutation); when session persistence
|
||||
* is absent the account is served unfiltered because membership cannot be
|
||||
* verified.
|
||||
*/
|
||||
readonly sessionIds: readonly SessionId[]
|
||||
|
||||
/**
|
||||
* Replace the display title durably.
|
||||
* @param title - New title; any string, duplicates across workspaces allowed.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
setTitle(title: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Record a session under this workspace. Idempotent: a session already on
|
||||
* the account resolves without writing. Otherwise the session's stored
|
||||
* header is read from session persistence and its `cwd`, normalized through
|
||||
* the same `fs.realpath` canon as workspace paths, must equal this
|
||||
* workspace's {@link path} — a missing persistence service, an unknown
|
||||
* session id, a header without `cwd`, a `cwd` that no longer resolves, or a
|
||||
* mismatched `cwd` all reject without touching the account (what cannot be
|
||||
* validated is not recorded).
|
||||
* @param sessionId - The session to record.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a session from this workspace's account. Idempotent: an id not on
|
||||
* the account resolves without writing. Never touches the session's own
|
||||
* stored log.
|
||||
* @param sessionId - The session to remove.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Live directory check, uncached: whether {@link path} currently exists and
|
||||
* is a directory. A missing directory never mutates the record — the
|
||||
* directory may only be temporarily moved.
|
||||
* @returns `'ok'` when the directory exists, `'missing-dir'` otherwise.
|
||||
*/
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
Reference in New Issue
Block a user