Sessions could be archived from the sidebar row menu but never restored: the registry-global archive set was a one-way display filter with no inverse RPC and no surface listing its members. WorkspaceRegistry.unarchiveSession drops one id from archivedSessionIds in one durable write on the same operation chain as archive, with no session-existence probe because a removal cannot introduce an unknown referent. The Workspace controller exposes it as workspace.unarchiveSession returning the complete archive set, which the existing archived follow increment already propagates, so no frame type and no persisted field changes. The new client package dsh-client-ui-settings-unarchive-sessions owns the restore surface: one localized settings.section page listing the archive set joined with loaded session summaries, newest archive first, searchable by title or workspace, with one Unarchive action per row.
200 lines
7.2 KiB
TypeScript
200 lines
7.2 KiB
TypeScript
/** Workspace command implementation and stable Remote failure mapping. */
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
|
import {
|
|
WorkspaceId,
|
|
WorkspaceMoveInvalidError,
|
|
WorkspaceOrderInvalidError,
|
|
WorkspaceUnknownSessionError,
|
|
} from '@deepseek-ai/dsh-workspace'
|
|
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { workspaceView } from './feed.ts'
|
|
import type {
|
|
WorkspaceArchiveSessionRequest,
|
|
WorkspaceArchiveValue,
|
|
WorkspaceCreateRequest,
|
|
WorkspaceCreateValue,
|
|
WorkspaceDeleteRequest,
|
|
WorkspaceDeleteValue,
|
|
WorkspaceInsertBeforeRequest,
|
|
WorkspaceInsertSessionBeforeRequest,
|
|
WorkspaceOrderValue,
|
|
WorkspaceRenameRequest,
|
|
WorkspaceUnarchiveSessionRequest,
|
|
WorkspaceValue,
|
|
} from './types.ts'
|
|
|
|
/** Implements Workspace mutations against the authoritative registry. */
|
|
export class WorkspaceCommands {
|
|
private operationTail = Promise.resolve()
|
|
|
|
/** @param ctx - Host context containing the Workspace registry. */
|
|
constructor(private readonly ctx: Context) {}
|
|
|
|
/**
|
|
* Create or resolve one Workspace over an existing directory.
|
|
* @param request - directory path to register.
|
|
* @returns the Workspace and whether this call created it.
|
|
*/
|
|
create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue> {
|
|
return this.enqueue(async () => {
|
|
try {
|
|
const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path)
|
|
if (existing !== undefined) {
|
|
return { workspace: workspaceView(existing), created: false }
|
|
}
|
|
const workspace = await this.ctx.workspaceRegistry.create(request.path)
|
|
return { workspace: workspaceView(workspace), created: true }
|
|
} catch (error) {
|
|
if (remoteErrorOf(error) !== undefined) throw error
|
|
throw new RemoteError(
|
|
'workspace/invalid-path',
|
|
`cannot create a Workspace at "${request.path}": ${errorMessage(error)}`,
|
|
{ path: request.path },
|
|
{ cause: error },
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Rename one Workspace after serializing title ownership checks.
|
|
* @param request - Workspace identity and proposed title.
|
|
* @returns the updated Workspace projection.
|
|
*/
|
|
rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue> {
|
|
const title = request.title.trim()
|
|
if (title === '') {
|
|
return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {}))
|
|
}
|
|
return this.enqueue(async () => {
|
|
const workspace = this.requireWorkspace(request.workspaceId)
|
|
if (title !== workspace.title) {
|
|
if (this.ctx.workspaceRegistry.list().some(candidate =>
|
|
candidate.id !== workspace.id && candidate.title === title)) {
|
|
throw new RemoteError(
|
|
'workspace/name-conflict',
|
|
`Workspace name '${title}' is already in use`,
|
|
{ name: title },
|
|
)
|
|
}
|
|
await workspace.setTitle(title)
|
|
}
|
|
return { workspace: workspaceView(workspace) }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete one Workspace registration without deleting its directory or Sessions.
|
|
* @param request - Workspace identity to remove.
|
|
* @returns deletion confirmation.
|
|
*/
|
|
delete(request: WorkspaceDeleteRequest): Promise<WorkspaceDeleteValue> {
|
|
return this.enqueue(async () => {
|
|
if (!await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId))) {
|
|
throw workspaceNotFound(request.workspaceId)
|
|
}
|
|
return { deleted: true }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Move one Workspace within the durable registry order.
|
|
* @param request - moved Workspace and optional anchor.
|
|
* @returns the complete resulting Workspace order.
|
|
*/
|
|
async insertBefore(request: WorkspaceInsertBeforeRequest): Promise<WorkspaceOrderValue> {
|
|
try {
|
|
const workspaceIds = await this.ctx.workspaceRegistry.insertBefore(
|
|
WorkspaceId(request.workspaceId),
|
|
request.beforeWorkspaceId === undefined
|
|
? undefined
|
|
: WorkspaceId(request.beforeWorkspaceId),
|
|
)
|
|
return { workspaceIds: [...workspaceIds] }
|
|
} catch (error) {
|
|
if (!(error instanceof WorkspaceOrderInvalidError)) throw error
|
|
throw workspaceNotFound(error.workspaceId)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move one accounted Session within a Workspace's manual order.
|
|
* @param request - Workspace, Session, and optional anchor identities.
|
|
* @returns the updated Workspace projection.
|
|
*/
|
|
async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<WorkspaceValue> {
|
|
const workspace = this.requireWorkspace(request.workspaceId)
|
|
try {
|
|
await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId)
|
|
} catch (error) {
|
|
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
|
|
throw new RemoteError(
|
|
'workspace/move-invalid',
|
|
error.message,
|
|
{
|
|
workspaceId: request.workspaceId,
|
|
sessionId: request.sessionId,
|
|
...request.beforeSessionId === undefined
|
|
? {}
|
|
: { beforeSessionId: request.beforeSessionId },
|
|
},
|
|
{ cause: error },
|
|
)
|
|
}
|
|
return { workspace: workspaceView(workspace) }
|
|
}
|
|
|
|
/**
|
|
* Add one known Session to the registry-global archive set.
|
|
* @param request - Session identity to archive.
|
|
* @returns the complete resulting archive set.
|
|
*/
|
|
async archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue> {
|
|
try {
|
|
await this.ctx.workspaceRegistry.archiveSession(request.sessionId)
|
|
} catch (error) {
|
|
if (!(error instanceof WorkspaceUnknownSessionError)) throw error
|
|
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
|
|
}
|
|
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
|
|
}
|
|
|
|
/**
|
|
* Drop one Session from the registry-global archive set. An id that is not
|
|
* archived is not an error: the call is idempotent, so a lost race with
|
|
* another surface resolves as a no-op.
|
|
* @param request - Session identity to unarchive.
|
|
* @returns the complete resulting archive set.
|
|
*/
|
|
async unarchiveSession(request: WorkspaceUnarchiveSessionRequest): Promise<WorkspaceArchiveValue> {
|
|
await this.ctx.workspaceRegistry.unarchiveSession(request.sessionId)
|
|
return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
|
|
}
|
|
|
|
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
|
|
const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId))
|
|
if (workspace === undefined) throw workspaceNotFound(workspaceId)
|
|
return workspace
|
|
}
|
|
|
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
const result = this.operationTail.then(operation)
|
|
this.operationTail = result.then(() => undefined, () => undefined)
|
|
return result
|
|
}
|
|
}
|
|
|
|
function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> {
|
|
return new RemoteError(
|
|
'workspace/not-found',
|
|
`Workspace "${workspaceId}" not found`,
|
|
{ workspaceId },
|
|
)
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|