Files
deepseek-harness/packages/api/workspace-controller/src/client/model.ts
T
imccyu 804b1ffbfc refactor(api): converge the Remote failure vocabulary and client surface
Single RemoteError with a merge-extensible, domain-prefixed code map;
owners throw at the failure point; streams surface marked failures;
clients consume ctx.remote directly with isRemoteFailure as the only
discrimination point and construct no failure instances.
2026-08-28 22:37:36 +08:00

360 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,
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
}
/**
* 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)]
}