Files
deepseek-harness/packages/api/workspace-controller/src/client/model.ts
T
Tianyi Cui 5f773a0ded feat(workspace): restore archived sessions from a settings page
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.
2026-09-13 16:45:45 +08:00

374 lines
13 KiB
TypeScript

/** Client-side Workspace state model shared by Remote transport and UI projection. */
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type {
WorkspaceArchiveSessionRequest,
WorkspaceArchiveValue,
WorkspaceBaseline,
WorkspaceCreateRequest,
WorkspaceCreateValue,
WorkspaceDeleteValue,
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspaceUnarchiveSessionRequest,
WorkspaceValue,
WorkspaceId,
WorkspaceView,
} from '../types.ts'
/** Complete generated `ctx.remote.workspace` namespace. */
export type WorkspaceRemote = TypertClientRemote['workspace']
/** Monotone Workspace-list arrival lifecycle. */
export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable Client Workspace state. */
export interface WorkspaceSnapshot {
readonly items: readonly WorkspaceView[]
/** Complete registry-global archive set in Host order. */
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
readonly state: 'idle' | 'loading' | 'error'
readonly phase: WorkspaceListPhase
readonly error: RemoteFailure | null
}
/** State operations emitted by a decoded Workspace follow generation. */
export interface WorkspaceFollowSink {
/** Replace all state from the generation baseline. */
replaceBaseline(value: WorkspaceBaseline): void
/** Merge one Workspace row. */
upsertView(workspace: WorkspaceView): void
/** Remove one Workspace row. */
removeView(workspaceId: WorkspaceId): void
/** Replace the Host-confirmed Workspace order. */
replaceOrder(workspaceIds: readonly WorkspaceId[]): void
/** Replace the complete archived Session set. */
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
}
/**
* Owns the Client Workspace projection, mutation echoes, and stream/unary race resolution.
*/
export class ClientWorkspaceModel implements WorkspaceFollowSink {
private items: readonly WorkspaceView[] = []
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
private state: WorkspaceSnapshot['state'] = 'loading'
private phase: WorkspaceListPhase = 'pending'
private error: RemoteFailure | null = null
/** Latest local reorder request; only its unary echo may install order. */
private orderRequestGeneration = 0
/** Increments on stream orders so a later remote commit outranks an older unary echo. */
private orderFrameGeneration = 0
/** Last complete order accepted from a baseline, increment, or current unary echo. */
private committedOrder: WorkspaceId[] = []
/** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */
private readonly removedIds = new Set<WorkspaceId>()
private readonly listeners = new Set<() => void>()
private snapshotCache: WorkspaceSnapshot
private snapshotDirty = false
private notificationPending = false
private notificationScheduled = false
private notificationGeneration = 0
/** @param remote - generated Workspace Remote namespace. */
constructor(private readonly remote: WorkspaceRemote) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Create or resolve a Workspace and merge the unary result immediately.
* @param input - existing absolute path to adopt.
* @returns generated Remote result.
*/
async create(input: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
const result = await this.remote.create(input)
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Rename a Workspace and merge the unary result immediately.
* @param workspaceId - target Workspace.
* @param title - new display title.
* @returns generated Remote result.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<RemoteResult<WorkspaceValue>> {
const result = await this.remote.rename({ workspaceId, title })
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Delete a Workspace and remove it from the local projection immediately.
* @param workspaceId - target Workspace.
* @returns generated Remote result.
*/
async delete(workspaceId: WorkspaceId): Promise<RemoteResult<WorkspaceDeleteValue>> {
const result = await this.remote.delete({ workspaceId })
if (result.ok) this.remove(workspaceId, true)
return result
}
/**
* Optimistically move a Workspace and reconcile the returned complete order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - anchor Workspace; omitted appends.
* @returns generated Remote result.
*/
async insertBefore(
workspaceId: WorkspaceId,
beforeWorkspaceId?: WorkspaceId,
): Promise<RemoteResult<WorkspaceOrderValue>> {
const requestGeneration = ++this.orderRequestGeneration
const frameGeneration = this.orderFrameGeneration
const localOrder = this.items.map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
const result = await this.remote.insertBefore({
workspaceId,
...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
})
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok)
}
return result
}
/**
* Move a Session within its Workspace and merge the returned row.
* @param workspaceId - owning Workspace.
* @param sessionId - accounted Session to move.
* @param beforeSessionId - accounted anchor; omitted appends.
* @returns generated Remote result.
*/
async insertSessionBefore(
workspaceId: WorkspaceInsertSessionBeforeRequest['workspaceId'],
sessionId: WorkspaceInsertSessionBeforeRequest['sessionId'],
beforeSessionId?: WorkspaceInsertSessionBeforeRequest['beforeSessionId'],
): Promise<RemoteResult<WorkspaceValue>> {
const result = await this.remote.insertSessionBefore({
workspaceId,
sessionId,
...beforeSessionId === undefined ? {} : { beforeSessionId },
})
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Archive one Session and install the returned complete archive set.
* @param sessionId - Session to archive.
* @returns generated Remote result.
*/
async archiveSession(
sessionId: WorkspaceArchiveSessionRequest['sessionId'],
): Promise<RemoteResult<WorkspaceArchiveValue>> {
const result = await this.remote.archiveSession({ sessionId })
if (result.ok) this.installArchived(result.value.archivedSessionIds)
return result
}
/**
* Unarchive one Session and install the returned complete archive set.
* @param sessionId - Session to unarchive.
* @returns generated Remote result.
*/
async unarchiveSession(
sessionId: WorkspaceUnarchiveSessionRequest['sessionId'],
): Promise<RemoteResult<WorkspaceArchiveValue>> {
const result = await this.remote.unarchiveSession({ sessionId })
if (result.ok) this.installArchived(result.value.archivedSessionIds)
return result
}
/**
* Replace the projection from one complete stream-generation baseline.
* @param baseline - complete Workspace and archive projection.
*/
replaceBaseline(baseline: WorkspaceBaseline): void {
this.orderFrameGeneration++
this.installViews(baseline.items)
this.installArchived(baseline.archivedSessionIds)
this.state = 'idle'
this.phase = 'ready'
this.error = null
this.invalidate()
}
/** Merge one decoded Workspace upsert from the current follow generation. */
upsertView(workspace: WorkspaceView): void {
this.upsert(workspace)
}
/** Apply one decoded Workspace removal from the current follow generation. */
removeView(workspaceId: WorkspaceId): void {
this.remove(workspaceId)
}
/** Replace Host-confirmed order from the current follow generation. */
replaceOrder(workspaceIds: readonly WorkspaceId[]): void {
this.orderFrameGeneration++
this.installOrder(workspaceIds, true)
}
/**
* Replace the archived Session set from the current follow generation.
* @param archivedSessionIds - complete Host-confirmed archive set.
*/
replaceArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void {
this.installArchived(archivedSessionIds)
}
/** Keep the last complete projection visible while a lost carrier reconnects. */
handleCarrierFailure(): void {
this.state = 'loading'
this.error = null
this.invalidate()
}
/**
* Publish a non-retryable stream or protocol failure.
* @param error - terminal stream failure.
*/
handleStreamFailure(error: unknown): void {
if (!isRemoteFailure(error)) throw error
this.state = 'error'
this.error = error
this.invalidate()
}
/**
* Subscribe to Workspace state invalidation.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Read the cached state, rebuilding it first when necessary.
* @returns the current stable Workspace list snapshot.
*/
getSnapshot(): WorkspaceSnapshot {
this.refreshSnapshot()
return this.snapshotCache
}
private buildSnapshot(): WorkspaceSnapshot {
return {
items: this.items,
archivedSessionIds: this.archivedSessionIds,
state: this.state,
phase: this.phase,
error: this.error,
}
}
private installArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void {
if (archivedSessionIds.length === this.archivedSessionIds.length
&& archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
this.archivedSessionIds = [...archivedSessionIds]
this.invalidate()
}
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
if (committed) this.committedOrder = [...workspaceIds]
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
const items = [...this.items].sort((left, right) =>
(rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER)
- (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER))
if (items.every((item, index) => item === this.items[index])) return
this.items = items
this.invalidate()
}
private upsert(view: WorkspaceView): void {
if (this.removedIds.has(view.workspaceId)) return
const index = this.items.findIndex(item => item.workspaceId === view.workspaceId)
const installed = this.items[index]
// Unary responses and stream increments race on separate requests. Keep
// the newest Host projection regardless of their arrival order.
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (!this.committedOrder.includes(view.workspaceId)) {
this.committedOrder = [view.workspaceId, ...this.committedOrder]
}
this.items = index === -1
? [view, ...this.items]
: this.items.map((item, position) => position === index ? view : item)
this.invalidate()
}
private remove(workspaceId: WorkspaceId, immediate = false): void {
this.removedIds.add(workspaceId)
this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
const items = this.items.filter(item => item.workspaceId !== workspaceId)
if (items.length === this.items.length) {
// A successful unary echo still publishes an earlier increment's
// pending removal before the user operation resolves.
if (immediate) this.invalidate(true)
return
}
this.items = items
this.invalidate(immediate)
}
private installViews(views: readonly WorkspaceView[]): void {
const installed = new Map<WorkspaceId, WorkspaceView>()
for (const view of views) {
if (!this.removedIds.has(view.workspaceId)) installed.set(view.workspaceId, view)
}
this.items = [...installed.values()]
this.committedOrder = views.map(view => view.workspaceId)
}
private invalidate(immediate = false): void {
this.snapshotDirty = true
this.notificationPending = true
if (immediate) {
this.notificationGeneration++
this.notificationScheduled = false
this.flush()
return
}
if (this.notificationScheduled) return
this.notificationScheduled = true
const generation = ++this.notificationGeneration
queueMicrotask(() => {
if (generation !== this.notificationGeneration) return
this.notificationScheduled = false
this.flush()
})
}
private flush(): void {
if (!this.notificationPending || this.listeners.size === 0) return
this.notificationPending = false
this.refreshSnapshot()
notifySubscribers(this.listeners, '[workspace-controller]')
}
private refreshSnapshot(): void {
if (!this.snapshotDirty) return
this.snapshotDirty = false
this.snapshotCache = this.buildSnapshot()
}
}
function insertIdBefore(
ids: readonly WorkspaceId[],
id: WorkspaceId,
beforeId?: WorkspaceId,
): WorkspaceId[] {
if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) {
return [...ids]
}
const without = ids.filter(candidate => candidate !== id)
const at = beforeId === undefined ? without.length : without.indexOf(beforeId)
return [...without.slice(0, at), id, ...without.slice(at)]
}