feat(workspace): add registry-global archivedSessionIds set

The workspace domain global singleton gains archivedSessionIds (schema
default [], version unchanged): a display-layer archive set layered over
workspace accounting. archiveSession() rides the registry operation
chain, validates the session against live/persisted headers, and is
idempotent; archived sessions keep their sessionIds slot so a future
unarchive restores position.
This commit is contained in:
imccyu
2026-07-31 02:39:26 +08:00
committed by imccyu
parent 6acdb6631d
commit b00cd0cd8c
6 changed files with 145 additions and 14 deletions

View File

@@ -49,6 +49,18 @@ export class WorkspaceNameConflictError extends Error {
}
}
/** An archiveSession request named a session neither live nor in session persistence. */
export class WorkspaceUnknownSessionError extends Error {
/**
* @param sessionId - The unknown session id.
* @param options - Standard error options (the header-read failure as `cause`).
*/
constructor(readonly sessionId: SessionId, options?: ErrorOptions) {
super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`, options)
this.name = 'WorkspaceUnknownSessionError'
}
}
declare module 'cordis' {
interface Context {
@@ -181,6 +193,38 @@ export class WorkspaceRegistry extends Service {
return this.enqueueOperation(() => this.deleteKnown(id))
}
/**
* The registry-global archive set: sessions hidden from every grouping
* surface. Archiving never touches workspace accounting — an archived
* session keeps its `sessionIds` slot so unarchiving restores its position.
* @returns the archived session ids in archive order.
*/
get archivedSessionIds(): readonly SessionId[] {
return this.requireState().archivedSessionIds
}
/**
* Archive one session durably. The session must exist (live or in session
* persistence); its workspace accounting — or lack of one — is irrelevant.
* An already archived id resolves without writing.
* @param sessionId - The session to archive.
* @returns resolution after durability.
*/
archiveSession(sessionId: SessionId): Promise<void> {
return this.enqueueOperation(async () => {
// The chain slot serializes against every other registry write, so this
// check-then-write pair cannot interleave with another archive.
if (this.requireState().archivedSessionIds.includes(sessionId)) return
try {
await this.readSessionHeader(sessionId)
} catch (error) {
throw new WorkspaceUnknownSessionError(sessionId, { cause: error })
}
const state = this.requireState()
await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] })
})
}
/**
* Resolve by canonical directory path without creating or mutating a
* workspace. A missing path rejects during `realpath`; an existing unowned
@@ -245,7 +289,11 @@ export class WorkspaceRegistry extends Service {
}
try {
await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] })
await this.setState({
initialized: true,
workspaceIds: [id, ...state.workspaceIds],
archivedSessionIds: state.archivedSessionIds,
})
} catch (error) {
this.entities.delete(id)
try {
@@ -276,6 +324,7 @@ export class WorkspaceRegistry extends Service {
const nextState = {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
archivedSessionIds: state.archivedSessionIds,
}
await this.setState({
...nextState,
@@ -329,7 +378,11 @@ export class WorkspaceRegistry extends Service {
)
}
await this.requireTable().delete(pending.workspaceId)
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
await this.setState({
initialized: state.initialized,
workspaceIds: state.workspaceIds,
archivedSessionIds: state.archivedSessionIds,
})
}
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
@@ -411,9 +464,9 @@ export class WorkspaceRegistry extends Service {
.map(([id]) => id)
if (!sameIds(state.workspaceIds, workspaceIds)) {
await this.setState({ initialized: false, workspaceIds })
await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
await this.setState({ initialized: true, workspaceIds })
await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds })
}
private validateStoredState(state: WorkspaceDomainState): void {

View File

@@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [
/**
* 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.
* `workspaceIds` is the authoritative display order. `archivedSessionIds` is
* the registry-global archive set layered over workspace accounting: an
* archived session keeps its `sessionIds` slot (unarchiving must restore the
* position), so the set never participates in the one-owner accounting
* invariant. Defaulted so records written before the field parse unchanged.
*/
export const workspaceDomainState = z.object({
initialized: z.boolean(),
workspaceIds: z.array(workspaceId),
archivedSessionIds: z.array(z.string().transform(SessionId)).default([]),
pendingMutation: workspacePendingMutation.optional(),
})
@@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({
version: 2,
global: {
schema: workspaceDomainState,
initial: { initialized: false, workspaceIds: [] },
initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] },
},
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
})