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:
@@ -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 {
|
||||
|
||||
@@ -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) },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user