Parcourir la source

refactor(workspace): move APIs into Workspace Controller

imccyu il y a 1 mois
Parent
commit
ae25df3ac6

+ 9 - 20
apps/web/tests/navigation-panes.e2e.ts

@@ -37,11 +37,10 @@ const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summ
 
 async function baselineResponse(
   page: Page,
-  method: 'session.list' | 'workspace.list',
 ): Promise<Response> {
   return page.waitForResponse(response => (
     response.request().method() === 'POST'
-    && new URL(response.url()).pathname === `/api/${method}`
+    && new URL(response.url()).pathname === '/api/session/list'
   ), { timeout: 30_000 })
 }
 
@@ -111,19 +110,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
         slotErrors.push(message.text())
       }
     })
-    // Initial navigation and list ownership settle only after both independent
-    // RPC baselines succeed; arm before navigation so neither response is missed.
-    const sessionBaseline = baselineResponse(page, 'session.list')
-    const workspaceBaseline = baselineResponse(page, 'workspace.list')
-    const [, sessionResponse, workspaceResponse] = await Promise.all([
+    // Arm before navigation so the Session response cannot be missed. The
+    // Workspace stream settles through the user-visible Ungrouped barrier.
+    const sessionBaseline = baselineResponse(page)
+    const [, sessionResponse] = await Promise.all([
       page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
       sessionBaseline,
-      workspaceBaseline,
-    ])
-    await Promise.all([
-      assertBaselineSucceeded(sessionResponse, 'session.list'),
-      assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
     ])
+    await assertBaselineSucceeded(sessionResponse, 'session.list')
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     // The frame mounts before the asynchronous session-list baseline lands.
     // Search must target the settled seeded row, not the startup input that
@@ -331,17 +325,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
         observerSlotErrors.push(message.text())
       }
     })
-    const observerSessionBaseline = baselineResponse(observer, 'session.list')
-    const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list')
-    const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([
+    const observerSessionBaseline = baselineResponse(observer)
+    const [, observerSessionResponse] = await Promise.all([
       observer.goto(scaffold.baseUrl, { waitUntil: 'load' }),
       observerSessionBaseline,
-      observerWorkspaceBaseline,
-    ])
-    await Promise.all([
-      assertBaselineSucceeded(observerSessionResponse, 'observer session.list'),
-      assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'),
     ])
+    await assertBaselineSucceeded(observerSessionResponse, 'observer session.list')
     await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
     await ensureSeedOpen(observer)
 

+ 91 - 0
packages/api/workspace-controller/src/client/index.ts

@@ -0,0 +1,91 @@
+/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */
+
+import {
+  RemoteSnapshotStream,
+  RemoteStreamCarrierError,
+  type ClientRemote,
+} from '@deepseek-ai/dsh-api-gateway/client'
+import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts'
+import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts'
+
+export { ClientWorkspaceModel } from './model.ts'
+export type {
+  WorkspaceFollowSink, WorkspaceListPhase, WorkspaceListSnapshot, WorkspaceRemote,
+} from './model.ts'
+
+type WorkspaceStreamRemote = Pick<ClientRemote, '$stream'> & {
+  readonly workspace: Pick<WorkspaceRemote, 'follow'>
+}
+
+type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
+
+/** Gateway-owned snapshot stream configured for Workspace state. */
+export type WorkspaceStateStream = RemoteSnapshotStream<
+  WorkspaceBaselineFrame,
+  WorkspaceFollowIncrement
+>
+
+/** Workspace Controller's Client row exports library values and installs no Cordis service. */
+export function apply(): void {}
+
+/** Domain sinks used by the Workspace state stream. */
+export interface WorkspaceStateStreamOptions {
+  /** Destinations for decoded Workspace state operations. */
+  readonly accept: WorkspaceFollowSink
+  /** Observe a retryable carrier loss before reconnection. */
+  readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
+  /** Publish a terminal business or protocol failure. */
+  readonly failed: (error: unknown) => void
+}
+
+/**
+ * Create the reconnecting Workspace state stream.
+ * @param remote - generated Workspace namespace and Gateway stream factory.
+ * @param options - Workspace state destinations.
+ * @returns an unstarted stream owned by the Client Workspace runtime.
+ */
+export function createWorkspaceStateStream(
+  remote: WorkspaceStreamRemote,
+  options: WorkspaceStateStreamOptions,
+): WorkspaceStateStream {
+  const stream = remote.$stream<WorkspaceFollowFrame>({
+    name: 'Workspace state stream',
+    open: signal => remote.workspace.follow(signal),
+    ended: accepted => accepted
+      ? new RemoteStreamCarrierError('Workspace state stream ended without a terminal result')
+      : new Error('Workspace state stream ended before its opening snapshot'),
+    ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }),
+  })
+  return new RemoteSnapshotStream<WorkspaceBaselineFrame, WorkspaceFollowIncrement>(stream, {
+    name: 'Workspace state stream',
+    isSnapshot: (frame): frame is WorkspaceBaselineFrame => frame.type === 'baseline',
+    replace: (frame) => { options.accept.replaceBaseline(frame.value) },
+    update: (frame) => { acceptIncrement(options.accept, frame) },
+    failed: options.failed,
+  })
+}
+
+function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void {
+  switch (frame.type) {
+    case 'upsert':
+      accept.upsertView(frame.workspace)
+      return
+    case 'remove':
+      accept.removeView(frame.workspaceId)
+      return
+    case 'order':
+      accept.replaceOrder(frame.workspaceIds)
+      return
+    case 'archived':
+      accept.replaceArchived(frame.archivedSessionIds)
+      return
+    /* v8 ignore next -- the generated Remote codec validates this closed union */
+    default:
+      return assertNever(frame)
+  }
+}
+
+/* v8 ignore next 3 -- closed-union backstop after generated Remote validation */
+function assertNever(value: never): never {
+  throw new Error(`unreachable Workspace increment: ${JSON.stringify(value)}`)
+}

+ 382 - 0
packages/api/workspace-controller/src/client/model.ts

@@ -0,0 +1,382 @@
+/** Client-side Workspace state model shared by Remote transport and UI projection. */
+
+import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
+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 WorkspaceListSnapshot {
+  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: WorkspaceListSnapshot['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: WorkspaceListSnapshot
+  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>> {
+    let result: RemoteResult<WorkspaceCreateValue>
+    try {
+      result = await this.remote.create(input)
+    } catch (error) {
+      result = failureResult(error)
+    }
+    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))
+    let result: RemoteResult<WorkspaceOrderValue>
+    try {
+      result = await this.remote.insertBefore({
+        workspaceId,
+        ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
+      })
+    } catch (error) {
+      if (requestGeneration === this.orderRequestGeneration
+        && frameGeneration === this.orderFrameGeneration) {
+        this.installOrder(this.committedOrder)
+      }
+      throw error
+    }
+    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 {
+    this.state = 'error'
+    this.error = failureOf(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(): WorkspaceListSnapshot {
+    this.refreshSnapshot()
+    return this.snapshotCache
+  }
+
+  private buildSnapshot(): WorkspaceListSnapshot {
+    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()
+    for (const listener of this.listeners) listener()
+  }
+
+  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)]
+}
+
+function failureResult<T>(error: unknown): RemoteResult<T> {
+  return { ok: false, error: failureOf(error) }
+}
+
+function failureOf(error: unknown): RemoteFailure {
+  return {
+    code: 'internal',
+    message: error instanceof Error ? error.message : String(error),
+    details: {},
+  }
+}

+ 196 - 0
packages/api/workspace-controller/src/commands.ts

@@ -0,0 +1,196 @@
+/** 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 { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { workspaceView } from './feed.ts'
+import type {
+  WorkspaceArchiveSessionRequest,
+  WorkspaceArchiveValue,
+  WorkspaceCreateRequest,
+  WorkspaceCreateValue,
+  WorkspaceDeleteRequest,
+  WorkspaceDeleteValue,
+  WorkspaceInsertBeforeRequest,
+  WorkspaceInsertSessionBeforeRequest,
+  WorkspaceOrderValue,
+  WorkspaceRenameRequest,
+  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 (error instanceof TypertRemoteFailure) throw error
+        throw failure(
+          'workspace-invalid-path',
+          `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`,
+          { path: request.path },
+        )
+      }
+    })
+  }
+
+  /**
+   * 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(failure(
+        '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 failure(
+            '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 failure(
+        'workspace-move-invalid',
+        error.message,
+        {
+          workspaceId: request.workspaceId,
+          sessionId: request.sessionId,
+          ...request.beforeSessionId === undefined
+            ? {}
+            : { beforeSessionId: request.beforeSessionId },
+        },
+      )
+    }
+    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 failure('session-not-found', error.message, { sessionId: 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): TypertRemoteFailure {
+  return failure(
+    'workspace-not-found',
+    `Workspace "${workspaceId}" not found`,
+    { workspaceId },
+  )
+}
+
+function failure(
+  code: string,
+  message: string,
+  details: object,
+): TypertRemoteFailure {
+  return new TypertRemoteFailure({ code, message, details })
+}
+
+function errorMessage(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}

+ 184 - 0
packages/api/workspace-controller/src/feed.ts

@@ -0,0 +1,184 @@
+/** Reconnect-safe Workspace baseline and increment producer. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
+import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
+import {
+  workspaceDomainState,
+  workspaceRecord,
+  WorkspaceId,
+} from '@deepseek-ai/dsh-workspace'
+import type {
+  WorkspaceBaseline,
+  WorkspaceFollowFrame,
+  WorkspaceView,
+} from './types.ts'
+
+/**
+ * Project one authoritative Workspace entity into its Remote value.
+ * @param workspace - authoritative registry entity.
+ * @returns detached Workspace projection for Remote consumers.
+ */
+export function workspaceView(workspace: Workspace): WorkspaceView {
+  return {
+    workspaceId: workspace.id,
+    path: workspace.path,
+    title: workspace.title,
+    sessionIds: [...workspace.sessionIds],
+    createdAt: workspace.createdAt,
+    updatedAt: workspace.updatedAt,
+  }
+}
+
+function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
+  const record: WorkspaceRecord = workspaceRecord.parse(value)
+  return {
+    workspaceId: WorkspaceId(workspaceId),
+    path: record.path,
+    title: record.title,
+    sessionIds: [...record.sessionIds],
+    createdAt: record.createdAt,
+    updatedAt: record.updatedAt,
+  }
+}
+
+/** Owns Workspace domain observation and all active follow generations. */
+export class WorkspaceFeed {
+  private readonly followers = new Set<WorkspaceFollower>()
+  private knownIds: Set<string>
+  private order: readonly string[]
+  private archived: readonly string[]
+
+  /** @param ctx - Host context containing the authoritative Workspace registry. */
+  constructor(private readonly ctx: Context) {
+    const baseline = ctx.workspaceRegistry.list()
+    this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
+    this.order = baseline.map(workspace => String(workspace.id))
+    this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
+    ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
+    ctx.effect(() => () => {
+      for (const follower of this.followers) follower.close()
+      this.followers.clear()
+    }, 'workspace-controller.feed')
+  }
+
+  /**
+   * Read the complete current projection synchronously.
+   * @returns all active Workspaces and archived Session identities.
+   */
+  baseline(): WorkspaceBaseline {
+    return {
+      items: this.ctx.workspaceRegistry.list().map(workspaceView),
+      archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
+    }
+  }
+
+  /**
+   * Open one generation beginning with a complete baseline.
+   * @param signal - generation cancellation.
+   * @returns baseline followed by ordered Workspace increments.
+   */
+  async *follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
+    signal.throwIfAborted()
+    const follower = new WorkspaceFollower()
+    this.followers.add(follower)
+    try {
+      yield { type: 'baseline', value: this.baseline() }
+      yield* follower.read(signal)
+    } finally {
+      this.followers.delete(follower)
+      follower.close()
+    }
+  }
+
+  private changed(change: DomainChanged): void {
+    if (change.domain !== 'workspace') return
+    if (change.table === '') {
+      if (change.operation !== 'put') return
+      const state = workspaceDomainState.parse(change.value)
+      const nextOrder = state.workspaceIds.map(String)
+      const orderChanged = !sameStrings(this.order, nextOrder)
+      for (const id of state.workspaceIds) {
+        if (this.knownIds.has(id)) continue
+        const workspace = this.ctx.workspaceRegistry.get(id)
+        if (workspace === undefined) {
+          throw new Error(`committed Workspace registry references missing Workspace "${id}"`)
+        }
+        this.knownIds.add(id)
+        this.publish({ type: 'upsert', workspace: workspaceView(workspace) })
+      }
+      this.order = nextOrder
+      if (orderChanged) this.publish({ type: 'order', workspaceIds: [...state.workspaceIds] })
+      const nextArchived = state.archivedSessionIds.map(String)
+      if (!sameStrings(this.archived, nextArchived)) {
+        this.archived = nextArchived
+        this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
+      }
+      return
+    }
+    if (change.table !== 'workspaces') return
+    if (change.operation === 'deleted') {
+      if (!this.knownIds.delete(change.key)) return
+      this.publish({ type: 'remove', workspaceId: WorkspaceId(change.key) })
+      return
+    }
+    if (!this.knownIds.has(change.key)) return
+    this.publish({
+      type: 'upsert',
+      workspace: changedWorkspaceView(change.key, change.value),
+    })
+  }
+
+  private publish(frame: Exclude<WorkspaceFollowFrame, { readonly type: 'baseline' }>): void {
+    for (const follower of this.followers) follower.push(frame)
+  }
+}
+
+function sameStrings(left: readonly string[], right: readonly string[]): boolean {
+  return left.length === right.length && left.every((value, index) => value === right[index])
+}
+
+class WorkspaceFollower {
+  private readonly frames: WorkspaceFollowFrame[] = []
+  private waiting: (() => void) | undefined
+  private closed = false
+
+  push(frame: WorkspaceFollowFrame): void {
+    /* v8 ignore next -- closed followers are removed before later publication can reach them. */
+    if (this.closed) return
+    this.frames.push(frame)
+    this.waiting?.()
+  }
+
+  close(): void {
+    if (this.closed) return
+    this.closed = true
+    this.waiting?.()
+  }
+
+  async *read(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
+    while (!this.closed && !signal.aborted) {
+      const frame = this.frames.shift()
+      if (frame !== undefined) {
+        yield frame
+        continue
+      }
+      await this.wait(signal)
+    }
+  }
+
+  private wait(signal: AbortSignal): Promise<void> {
+    return new Promise((resolve) => {
+      const finish = (): void => {
+        signal.removeEventListener('abort', finish)
+        /* v8 ignore next -- one read owns the sole installed wait callback. */
+        if (this.waiting === finish) this.waiting = undefined
+        resolve()
+      }
+      this.waiting = finish
+      signal.addEventListener('abort', finish, { once: true })
+      /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */
+      if (signal.aborted || this.closed || this.frames.length > 0) finish()
+    })
+  }
+}

+ 116 - 0
packages/api/workspace-controller/src/index.ts

@@ -0,0 +1,116 @@
+/** Host Workspace Remote owner: explicit commands and reconnect-safe state. */
+
+import { Context } from '@deepseek-ai/cordis'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { WorkspaceCommands } from './commands.ts'
+import { WorkspaceFeed } from './feed.ts'
+import type {
+  WorkspaceArchiveSessionRequest,
+  WorkspaceArchiveValue,
+  WorkspaceCreateRequest,
+  WorkspaceCreateValue,
+  WorkspaceDeleteRequest,
+  WorkspaceDeleteValue,
+  WorkspaceFollowFrame,
+  WorkspaceInsertBeforeRequest,
+  WorkspaceInsertSessionBeforeRequest,
+  WorkspaceOrderValue,
+  WorkspaceRenameRequest,
+  WorkspaceValue,
+} from './types.ts'
+
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Host Workspace business API and Remote namespace owner. */
+    workspaceController: WorkspaceController
+  }
+}
+
+/** Host service backing the generated `ctx.remote.workspace` namespace. */
+export class WorkspaceController extends TypertRemoteService {
+  static inject = ['typert', 'workspaceRegistry']
+
+  private readonly commands: WorkspaceCommands
+  private readonly feed: WorkspaceFeed
+
+  /** @param ctx - Host context containing the Workspace registry. */
+  constructor(ctx: Context) {
+    super(ctx, 'workspaceController', { namespace: 'workspace' })
+    this.commands = new WorkspaceCommands(ctx)
+    this.feed = new WorkspaceFeed(ctx)
+  }
+
+  /**
+   * Create or idempotently resolve one Workspace over an existing directory.
+   * @param request - directory path to register.
+   * @returns the Workspace and whether this call created it.
+   */
+  @Remote('create')
+  create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue> {
+    return this.commands.create(request)
+  }
+
+  /**
+   * Rename one Workspace to a unique non-blank title.
+   * @param request - Workspace identity and proposed title.
+   * @returns the updated Workspace projection.
+   */
+  @Remote('rename')
+  rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue> {
+    return this.commands.rename(request)
+  }
+
+  /**
+   * Remove one Workspace registration while retaining files and Sessions.
+   * @param request - Workspace identity to remove.
+   * @returns deletion confirmation.
+   */
+  @Remote('delete')
+  delete(request: WorkspaceDeleteRequest): Promise<WorkspaceDeleteValue> {
+    return this.commands.delete(request)
+  }
+
+  /**
+   * Move one Workspace within the registry display order.
+   * @param request - moved Workspace and optional anchor.
+   * @returns the complete resulting Workspace order.
+   */
+  @Remote('insertBefore')
+  insertBefore(request: WorkspaceInsertBeforeRequest): Promise<WorkspaceOrderValue> {
+    return this.commands.insertBefore(request)
+  }
+
+  /**
+   * Move one accounted Session within a Workspace.
+   * @param request - Workspace, Session, and optional anchor identities.
+   * @returns the updated Workspace projection.
+   */
+  @Remote('insertSessionBefore')
+  insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<WorkspaceValue> {
+    return this.commands.insertSessionBefore(request)
+  }
+
+  /**
+   * Hide one known Session from Workspace grouping surfaces.
+   * @param request - Session identity to archive.
+   * @returns the complete resulting archive set.
+   */
+  @Remote('archiveSession')
+  archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue> {
+    return this.commands.archiveSession(request)
+  }
+
+  /**
+   * Stream a complete Workspace baseline followed by ordered increments.
+   * @param signal - generation cancellation.
+   * @returns baseline followed by ordered Workspace increments.
+   */
+  @Remote({ mode: 'stream' })
+  follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
+    return this.feed.follow(signal)
+  }
+}
+
+export default WorkspaceController

+ 20 - 0
packages/api/workspace-controller/src/invariant.ts

@@ -0,0 +1,20 @@
+/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-workspace-controller/invariant */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-api-workspace-controller'
+
+/** Cordis companion plugin name. */
+export const name = 'api-workspace-controller-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: Workspace Registry owns persistence; every stream generation is a full projection. */
+const install: InvariantInstaller = () => {}
+
+/** Register this package's invariant companion. */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 122 - 0
packages/api/workspace-controller/src/types.ts

@@ -0,0 +1,122 @@
+/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */
+
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+
+export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+
+/** One durable Workspace projected for browser consumers. */
+export interface WorkspaceView {
+  readonly workspaceId: WorkspaceId
+  /** Canonical host directory path. */
+  readonly path: string
+  /** User-visible title. */
+  readonly title: string
+  /** Sessions accounted to this Workspace in manual order. */
+  readonly sessionIds: readonly SessionId[]
+  /** ISO-8601 creation instant. */
+  readonly createdAt: string
+  /** ISO-8601 last-mutation instant. */
+  readonly updatedAt: string
+}
+
+/** Stable Workspace failure details returned by unary methods. */
+export interface WorkspaceErrorDetailsMap {
+  'bad-request': Record<never, never>
+  'workspace-invalid-path': { readonly path: string }
+  'workspace-not-found': { readonly workspaceId: WorkspaceId }
+  'workspace-name-conflict': { readonly name: string }
+  'workspace-move-invalid': {
+    readonly workspaceId: WorkspaceId
+    readonly sessionId: SessionId
+    readonly beforeSessionId?: SessionId
+  }
+  'session-not-found': { readonly sessionId: SessionId }
+}
+
+/** Workspace business failure returned without throwing a carrier error. */
+export type WorkspaceError = {
+  [Code in keyof WorkspaceErrorDetailsMap]: {
+    readonly code: Code
+    readonly message: string
+    readonly details: WorkspaceErrorDetailsMap[Code]
+  }
+}[keyof WorkspaceErrorDetailsMap]
+
+/** Existing directory requested for Workspace adoption. */
+export interface WorkspaceCreateRequest {
+  readonly path: string
+}
+
+/** Created or previously registered Workspace. */
+export interface WorkspaceCreateValue {
+  readonly workspace: WorkspaceView
+  readonly created: boolean
+}
+
+/** Workspace title mutation. */
+export interface WorkspaceRenameRequest {
+  readonly workspaceId: WorkspaceId
+  readonly title: string
+}
+
+/** Workspace mutation returning the complete changed row. */
+export interface WorkspaceValue {
+  readonly workspace: WorkspaceView
+}
+
+/** Workspace registration deletion. */
+export interface WorkspaceDeleteRequest {
+  readonly workspaceId: WorkspaceId
+}
+
+/** Receipt after one Workspace registration is deleted. */
+export interface WorkspaceDeleteValue {
+  readonly deleted: true
+}
+
+/** DOM-insertBefore-like Workspace order mutation. */
+export interface WorkspaceInsertBeforeRequest {
+  readonly workspaceId: WorkspaceId
+  readonly beforeWorkspaceId?: WorkspaceId
+}
+
+/** Complete Workspace registry order after a mutation. */
+export interface WorkspaceOrderValue {
+  readonly workspaceIds: readonly WorkspaceId[]
+}
+
+/** DOM-insertBefore-like Session membership order mutation. */
+export interface WorkspaceInsertSessionBeforeRequest {
+  readonly workspaceId: WorkspaceId
+  readonly sessionId: SessionId
+  readonly beforeSessionId?: SessionId
+}
+
+/** Session requested for archival from Workspace grouping surfaces. */
+export interface WorkspaceArchiveSessionRequest {
+  readonly sessionId: SessionId
+}
+
+/** Complete archived Session set after a mutation. */
+export interface WorkspaceArchiveValue {
+  readonly archivedSessionIds: readonly SessionId[]
+}
+
+/** Complete reconnect baseline for Workspace browser state. */
+export interface WorkspaceBaseline {
+  readonly items: readonly WorkspaceView[]
+  readonly archivedSessionIds: readonly SessionId[]
+}
+
+/** One ordered Workspace change after a generation's baseline. */
+export type WorkspaceFollowIncrement =
+  | { readonly type: 'upsert'; readonly workspace: WorkspaceView }
+  | { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
+  | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
+  | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }
+
+/** Workspace state stream; every generation starts with exactly one baseline. */
+export type WorkspaceFollowFrame =
+  | { readonly type: 'baseline'; readonly value: WorkspaceBaseline }
+  | WorkspaceFollowIncrement

+ 384 - 0
packages/api/workspace-controller/tests/model.client.spec.ts

@@ -0,0 +1,384 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+  ClientWorkspaceModel, type WorkspaceRemote,
+} from '../src/client/index.ts'
+import type {
+  WorkspaceArchiveSessionRequest,
+  WorkspaceArchiveValue,
+  WorkspaceCreateRequest,
+  WorkspaceCreateValue,
+  WorkspaceDeleteRequest,
+  WorkspaceDeleteValue,
+  WorkspaceFollowFrame,
+  WorkspaceInsertBeforeRequest,
+  WorkspaceInsertSessionBeforeRequest,
+  WorkspaceOrderValue,
+  WorkspaceRenameRequest,
+  WorkspaceValue,
+  WorkspaceError,
+  WorkspaceId,
+  WorkspaceView,
+} from '../src/types.ts'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+const sid = (id: string): SessionId => id as SessionId
+const wid = (id: string): WorkspaceId => id as WorkspaceId
+
+function workspace(
+  id: string,
+  sessionIds: readonly SessionId[] = [],
+  updatedAt = '2026-01-01T00:00:00.000Z',
+): WorkspaceView {
+  return {
+    workspaceId: wid(id),
+    path: `/w/${id}`,
+    title: id,
+    sessionIds,
+    createdAt: '2026-01-01T00:00:00.000Z',
+    updatedAt,
+  }
+}
+
+function remoteOk<T>(value: T): RemoteResult<T> {
+  return { ok: true, value }
+}
+
+function workspaceError(error: WorkspaceError): RemoteResult<never> {
+  return { ok: false, error }
+}
+
+interface Deferred<T> {
+  readonly promise: Promise<T>
+  resolve(value: T): void
+  reject(error: unknown): void
+}
+
+function deferred<T>(): Deferred<T> {
+  let resolve!: (value: T) => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<T>((accept, fail) => {
+    resolve = accept
+    reject = fail
+  })
+  return { promise, reject, resolve }
+}
+
+class FakeWorkspaceRemote implements WorkspaceRemote {
+  readonly calls: Array<{ readonly method: string; readonly request: unknown }> = []
+  onCreate: (request: WorkspaceCreateRequest) => Promise<RemoteResult<WorkspaceCreateValue>> = request =>
+    Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true }))
+  onRename: (request: WorkspaceRenameRequest) => Promise<RemoteResult<WorkspaceValue>> = request =>
+    Promise.resolve(remoteOk({ workspace: { ...workspace(String(request.workspaceId)), title: request.title } }))
+  onDelete: (_request: WorkspaceDeleteRequest) => Promise<RemoteResult<WorkspaceDeleteValue>> = () =>
+    Promise.resolve(remoteOk({ deleted: true }))
+  onInsertBefore: (
+    request: WorkspaceInsertBeforeRequest,
+  ) => Promise<RemoteResult<WorkspaceOrderValue>> = request =>
+    Promise.resolve(remoteOk({ workspaceIds: [request.workspaceId] }))
+  onInsertSessionBefore: (
+    request: WorkspaceInsertSessionBeforeRequest,
+  ) => Promise<RemoteResult<WorkspaceValue>> = request => Promise.resolve(remoteOk({
+    workspace: workspace(String(request.workspaceId), [request.sessionId]),
+  }))
+  onArchiveSession: (
+    request: WorkspaceArchiveSessionRequest,
+  ) => Promise<RemoteResult<WorkspaceArchiveValue>> = request =>
+    Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
+
+  create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
+    this.record('create', request)
+    return this.onCreate(request)
+  }
+
+  rename(request: WorkspaceRenameRequest): Promise<RemoteResult<WorkspaceValue>> {
+    this.record('rename', request)
+    return this.onRename(request)
+  }
+
+  delete(request: WorkspaceDeleteRequest): Promise<RemoteResult<WorkspaceDeleteValue>> {
+    this.record('delete', request)
+    return this.onDelete(request)
+  }
+
+  insertBefore(request: WorkspaceInsertBeforeRequest): Promise<RemoteResult<WorkspaceOrderValue>> {
+    this.record('insertBefore', request)
+    return this.onInsertBefore(request)
+  }
+
+  insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<RemoteResult<WorkspaceValue>> {
+    this.record('insertSessionBefore', request)
+    return this.onInsertSessionBefore(request)
+  }
+
+  archiveSession(request: WorkspaceArchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
+    this.record('archiveSession', request)
+    return this.onArchiveSession(request)
+  }
+
+  async *follow(_signal?: AbortSignal): AsyncGenerator<WorkspaceFollowFrame> {}
+
+  private record(method: string, request: unknown): void {
+    this.calls.push({ method, request })
+  }
+}
+
+function modelFor(remote = new FakeWorkspaceRemote()): ClientWorkspaceModel {
+  return new ClientWorkspaceModel(remote)
+}
+
+function baseline(
+  model: ClientWorkspaceModel,
+  items: readonly WorkspaceView[] = [],
+  archivedSessionIds: readonly SessionId[] = [],
+): void {
+  model.replaceBaseline({ items, archivedSessionIds })
+}
+
+describe('ClientWorkspaceModel', () => {
+  it('replaces reconnect state and applies ordered increments', () => {
+    const model = modelFor()
+    expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' })
+    baseline(model, [workspace('old'), workspace('kept')])
+    model.upsertView(workspace('new'))
+    model.replaceOrder([wid('kept'), wid('new'), wid('old')])
+    model.replaceArchived([sid('hidden')])
+    model.removeView(wid('old'))
+    expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', archivedSessionIds: ['hidden'] })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept', 'new'])
+
+    baseline(model, [workspace('fresh')])
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['fresh'])
+    expect(model.getSnapshot().archivedSessionIds).toEqual([])
+  })
+
+  it('keeps the last baseline during retry and exposes a terminal stream failure', () => {
+    const model = modelFor()
+    baseline(model, [workspace('visible')])
+    model.handleCarrierFailure()
+    expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible'])
+    model.handleStreamFailure(new Error('wire down'))
+    expect(model.getSnapshot()).toMatchObject({
+      phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' },
+    })
+    model.handleStreamFailure('plain failure')
+    expect(model.getSnapshot().error?.message).toBe('plain failure')
+    baseline(model, [workspace('restored')])
+    expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null })
+  })
+
+  it('creates by path, prepends the returned row, and folds rejected calls', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    remote.onCreate = request => Promise.resolve(remoteOk({
+      workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
+      created: request.path === '/w/created',
+    }))
+    await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
+    expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } })
+    expect(model.getSnapshot().items[0]?.workspaceId).toBe('created')
+
+    remote.onCreate = () => Promise.reject(new Error('create transport'))
+    await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({
+      ok: false, error: { code: 'internal', message: 'create transport' },
+    })
+  })
+
+  it('lets newer stream order outrank unary echoes and rolls failures back', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('one'), workspace('two'), workspace('three')])
+
+    const gate = deferred<RemoteResult<WorkspaceOrderValue>>()
+    remote.onInsertBefore = () => gate.promise
+    const pending = model.insertBefore(wid('three'), wid('one'))
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
+    model.replaceOrder([wid('one'), wid('three'), wid('two')])
+    gate.resolve(remoteOk({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
+    await pending
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
+
+    remote.onInsertBefore = () => Promise.resolve(workspaceError({
+      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') },
+    }))
+    const rejected = model.insertBefore(wid('three'))
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
+    await expect(rejected).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
+
+    remote.onInsertBefore = () => Promise.reject(new Error('transport down'))
+    const disconnected = model.insertBefore(wid('three'), wid('one'))
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
+    await expect(disconnected).rejects.toThrow('transport down')
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
+  })
+
+  it('keeps a newer optimistic reorder when an older transport call rejects', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('one'), workspace('two'), workspace('three')])
+    const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
+    const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
+    let request = 0
+    remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
+
+    const first = model.insertBefore(wid('three'), wid('one'))
+    const second = model.insertBefore(wid('two'), wid('three'))
+    firstGate.reject(new Error('first transport failed'))
+    await expect(first).rejects.toThrow('first transport failed')
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
+    secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] }))
+    await expect(second).resolves.toMatchObject({ ok: true })
+  })
+
+  it('rolls overlapping rejected reorders back to the last Host order', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('one'), workspace('two'), workspace('three')])
+    const firstGate = deferred<RemoteResult<WorkspaceOrderValue>>()
+    const secondGate = deferred<RemoteResult<WorkspaceOrderValue>>()
+    let request = 0
+    remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
+
+    const first = model.insertBefore(wid('three'), wid('one'))
+    const second = model.insertBefore(wid('two'), wid('three'))
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
+    firstGate.resolve(workspaceError({
+      code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') },
+    }))
+    await expect(first).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
+    secondGate.resolve(workspaceError({
+      code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') },
+    }))
+    await expect(second).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
+  })
+
+  it('retains removal tombstones across later baselines', () => {
+    const model = modelFor()
+    baseline(model, [workspace('gone'), workspace('kept')])
+    model.removeView(wid('gone'))
+    model.removeView(wid('gone'))
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
+    baseline(model, [workspace('gone')])
+    expect(model.getSnapshot().items).toEqual([])
+  })
+
+  it('does not let delayed unary data resurrect a removed Workspace', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('gone')])
+    const gate = deferred<RemoteResult<WorkspaceValue>>()
+    remote.onRename = () => gate.promise
+    const rename = model.rename(wid('gone'), 'late')
+    model.removeView(wid('gone'))
+    gate.resolve(remoteOk({ workspace: { ...workspace('gone'), title: 'late' } }))
+    await expect(rename).resolves.toMatchObject({ ok: true })
+    expect(model.getSnapshot().items).toEqual([])
+  })
+
+  it('applies Workspace mutation echoes and leaves failed results unchanged', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')])
+
+    remote.onRename = () => Promise.resolve(workspaceError({
+      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
+    }))
+    await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().items[0]?.title).toBe('one')
+
+    remote.onDelete = () => Promise.resolve(workspaceError({
+      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') },
+    }))
+    await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().items).toHaveLength(1)
+
+    remote.onInsertSessionBefore = request => Promise.resolve(remoteOk({
+      workspace: workspace('one', [request.sessionId, sid('first')], '2026-02-01T00:00:00.000Z'),
+    }))
+    await expect(model.insertSessionBefore(wid('one'), sid('second'), sid('first')))
+      .resolves.toMatchObject({ ok: true })
+    expect(remote.calls).toContainEqual({
+      method: 'insertSessionBefore',
+      request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' },
+    })
+
+    remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({
+      code: 'workspace-move-invalid',
+      message: 'invalid move',
+      details: { workspaceId: wid('one'), sessionId: sid('second') },
+    }))
+    await expect(model.insertSessionBefore(wid('one'), sid('second')))
+      .resolves.toMatchObject({ ok: false })
+    expect(remote.calls).toContainEqual({
+      method: 'insertSessionBefore',
+      request: { workspaceId: 'one', sessionId: 'second' },
+    })
+
+    remote.onArchiveSession = () => Promise.resolve(workspaceError({
+      code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') },
+    }))
+    await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false })
+    expect(model.getSnapshot().archivedSessionIds).toEqual(['archived'])
+    remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] }))
+    await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
+    expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh'])
+  })
+
+  it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
+    const model = modelFor()
+    baseline(model, [
+      workspace('one', [], '2026-02-01T00:00:00.000Z'),
+      workspace('two'),
+    ])
+    model.upsertView(workspace('one', [], '2025-12-01T00:00:00.000Z'))
+    expect(model.getSnapshot().items[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z')
+    model.upsertView(workspace('one', [sid('new')], '2026-03-01T00:00:00.000Z'))
+    expect(model.getSnapshot().items[0]?.sessionIds).toEqual(['new'])
+
+    model.replaceOrder([wid('one')])
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
+    model.replaceOrder([wid('two')])
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
+    model.replaceOrder([wid('one')])
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
+
+    await expect(model.insertBefore(wid('one'), wid('one'))).resolves.toMatchObject({ ok: true })
+    expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two'])
+  })
+
+  it('notifies subscribers and cancels a queued notification after an immediate delete echo', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('gone')])
+    await Promise.resolve()
+    const listener = vi.fn()
+    const unsubscribe = model.subscribe(listener)
+
+    const deletion = model.delete(wid('gone'))
+    model.removeView(wid('gone'))
+    await expect(deletion).resolves.toMatchObject({ ok: true })
+    expect(listener).toHaveBeenCalledOnce()
+    await Promise.resolve()
+    expect(listener).toHaveBeenCalledOnce()
+
+    unsubscribe()
+    model.handleCarrierFailure()
+    await Promise.resolve()
+    expect(listener).toHaveBeenCalledOnce()
+  })
+
+  it('removes from a unary delete echo before the operation resolves', async () => {
+    const remote = new FakeWorkspaceRemote()
+    const model = modelFor(remote)
+    baseline(model, [workspace('gone')])
+    await expect(model.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
+    expect(remote.calls).toContainEqual({ method: 'delete', request: { workspaceId: 'gone' } })
+    expect(model.getSnapshot().items).toEqual([])
+    model.removeView(wid('gone'))
+    expect(model.getSnapshot().items).toEqual([])
+  })
+})

+ 246 - 0
packages/api/workspace-controller/tests/transport.client.spec.ts

@@ -0,0 +1,246 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+  RemoteStream,
+  RemoteStreamCarrierError,
+  type RemoteStreamOptions,
+} from '@deepseek-ai/dsh-api-gateway/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import {
+  apply,
+  createWorkspaceStateStream,
+  type WorkspaceFollowSink,
+  type WorkspaceRemote,
+} from '../src/client/index.ts'
+import type {
+  WorkspaceArchiveSessionRequest,
+  WorkspaceArchiveValue,
+  WorkspaceCreateRequest,
+  WorkspaceCreateValue,
+  WorkspaceDeleteRequest,
+  WorkspaceDeleteValue,
+  WorkspaceFollowFrame,
+  WorkspaceInsertBeforeRequest,
+  WorkspaceInsertSessionBeforeRequest,
+  WorkspaceOrderValue,
+  WorkspaceRenameRequest,
+  WorkspaceValue,
+} from '../src/types.ts'
+
+interface Generation {
+  readonly frames: readonly WorkspaceFollowFrame[]
+  readonly error?: unknown
+  readonly hold?: boolean
+}
+
+const AVAILABLE_CONNECTION = {
+  hostDescription: {
+    getSnapshot: () => ({
+      version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
+    }),
+    subscribe: () => () => {},
+  },
+}
+
+function workspaceClient(
+  remote: WorkspaceRemote,
+  connection: Pick<ConnectionHandle, 'hostDescription'> = AVAILABLE_CONNECTION,
+) {
+  return {
+    workspace: remote,
+    $stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
+  }
+}
+
+const baseline = (id?: string): Extract<WorkspaceFollowFrame, { type: 'baseline' }> => ({
+  type: 'baseline',
+  value: {
+    items: id === undefined ? [] : [{
+      workspaceId: id as never,
+      path: `/work/${id}`,
+      title: id,
+      sessionIds: [],
+      createdAt: '2026-01-01T00:00:00.000Z',
+      updatedAt: '2026-01-01T00:00:00.000Z',
+    }],
+    archivedSessionIds: [],
+  },
+})
+
+function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowSink {
+  const ignore = (): void => {}
+  return {
+    replaceBaseline: ignore,
+    upsertView: ignore,
+    removeView: ignore,
+    replaceOrder: ignore,
+    replaceArchived: ignore,
+    ...overrides,
+  }
+}
+
+class ScriptedWorkspaceRemote implements WorkspaceRemote {
+  readonly signals: AbortSignal[] = []
+  calls = 0
+
+  constructor(private readonly generations: readonly Generation[]) {}
+
+  create(_request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
+    throw new Error('unused')
+  }
+
+  rename(_request: WorkspaceRenameRequest): Promise<RemoteResult<WorkspaceValue>> {
+    throw new Error('unused')
+  }
+
+  delete(_request: WorkspaceDeleteRequest): Promise<RemoteResult<WorkspaceDeleteValue>> {
+    throw new Error('unused')
+  }
+
+  insertBefore(_request: WorkspaceInsertBeforeRequest): Promise<RemoteResult<WorkspaceOrderValue>> {
+    throw new Error('unused')
+  }
+
+  insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise<RemoteResult<WorkspaceValue>> {
+    throw new Error('unused')
+  }
+
+  archiveSession(_request: WorkspaceArchiveSessionRequest): Promise<RemoteResult<WorkspaceArchiveValue>> {
+    throw new Error('unused')
+  }
+
+  async *follow(signal = new AbortController().signal): AsyncIterable<WorkspaceFollowFrame> {
+    const generation = this.generations[this.calls++]
+    if (generation === undefined) throw new Error('no scripted Workspace generation')
+    this.signals.push(signal)
+    for (const frame of generation.frames) yield frame
+    if (generation.error !== undefined) throw generation.error
+    if (generation.hold === true && !signal.aborted) {
+      await new Promise<void>((resolve) => {
+        signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }
+  }
+}
+
+describe('Workspace Client snapshot adapter', () => {
+  it('installs no Client service and maps the baseline plus every increment', async () => {
+    apply()
+    const opening = baseline('one')
+    const workspace = opening.value.items[0]!
+    const remote = new ScriptedWorkspaceRemote([{
+      frames: [
+        opening,
+        { type: 'upsert', workspace },
+        { type: 'remove', workspaceId: workspace.workspaceId },
+        { type: 'order', workspaceIds: [workspace.workspaceId] },
+        { type: 'archived', archivedSessionIds: ['session-one' as never] },
+      ],
+      hold: true,
+    }])
+    const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
+    const upsertView = vi.fn<WorkspaceFollowSink['upsertView']>()
+    const removeView = vi.fn<WorkspaceFollowSink['removeView']>()
+    const replaceOrder = vi.fn<WorkspaceFollowSink['replaceOrder']>()
+    const replaceArchived = vi.fn<WorkspaceFollowSink['replaceArchived']>()
+    const accept = accepts({
+      replaceBaseline,
+      upsertView,
+      removeView,
+      replaceOrder,
+      replaceArchived,
+    })
+    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+      accept,
+      failed: vi.fn(),
+    })
+
+    stream.start()
+    stream.start()
+    await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() })
+
+    expect(replaceBaseline).toHaveBeenCalledWith(opening.value)
+    expect(upsertView).toHaveBeenCalledWith(workspace)
+    expect(removeView).toHaveBeenCalledWith(workspace.workspaceId)
+    expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId])
+    expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
+    await stream.dispose()
+    expect(remote.signals[0]?.aborted).toBe(true)
+  })
+
+  it('retains the old state across carrier loss and applies the replacement baseline', async () => {
+    const carrier = new RemoteStreamCarrierError('socket lost')
+    const remote = new ScriptedWorkspaceRemote([
+      { frames: [baseline('old')], error: carrier },
+      { frames: [baseline('fresh')], hold: true },
+    ])
+    const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
+    const carrierFailed = vi.fn()
+    const failed = vi.fn()
+    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+      accept: accepts({ replaceBaseline }),
+      carrierFailed,
+      failed,
+    })
+
+    stream.start()
+    await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) })
+
+    expect(replaceBaseline.mock.calls.map(([value]) => value.items[0]?.title)).toEqual(['old', 'fresh'])
+    expect(carrierFailed).toHaveBeenCalledWith(carrier)
+    expect(failed).not.toHaveBeenCalled()
+    await stream.dispose()
+  })
+
+  it.each([
+    {
+      name: 'an increment before the baseline',
+      frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[],
+      message: 'update before its opening snapshot',
+    },
+    {
+      name: 'a duplicate baseline',
+      frames: [baseline(), baseline()] as WorkspaceFollowFrame[],
+      message: 'more than one opening snapshot',
+    },
+    {
+      name: 'a normal end before the baseline',
+      frames: [] as WorkspaceFollowFrame[],
+      message: 'ended before its opening snapshot',
+    },
+  ])('reports $name as a terminal failure', async ({ frames, message }) => {
+    const failed = vi.fn()
+    const stream = createWorkspaceStateStream(
+      workspaceClient(new ScriptedWorkspaceRemote([{ frames }])),
+      { accept: accepts(), failed },
+    )
+
+    stream.start()
+    await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
+    const failure: unknown = failed.mock.calls[0]?.[0]
+    expect(failure).toBeInstanceOf(Error)
+    if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure')
+    expect(failure.message).toContain(message)
+    await stream.dispose()
+  })
+
+  it('restarts a live generation without reporting cancellation as failure', async () => {
+    const remote = new ScriptedWorkspaceRemote([
+      { frames: [baseline('first')], hold: true },
+      { frames: [baseline('second')], hold: true },
+    ])
+    const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
+    const failed = vi.fn()
+    const stream = createWorkspaceStateStream(workspaceClient(remote), {
+      accept: accepts({ replaceBaseline }),
+      failed,
+    })
+
+    stream.start()
+    await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledOnce() })
+    stream.restart()
+    await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) })
+    expect(failed).not.toHaveBeenCalled()
+    await stream.dispose()
+  })
+})

+ 333 - 0
packages/api/workspace-controller/tests/workspace-controller.host.spec.ts

@@ -0,0 +1,333 @@
+import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import Storage from '@deepseek-ai/dsh-storage'
+import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
+import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import WorkspaceController from '../src/index.ts'
+import { WorkspaceFeed } from '../src/feed.ts'
+import type { WorkspaceFollowFrame } from '../src/types.ts'
+import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
+
+const roots: Context[] = []
+
+afterEach(async () => {
+  await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+interface Deferred<T> {
+  readonly promise: Promise<T>
+  resolve(value: T): void
+}
+
+function deferred<T>(): Deferred<T> {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((settle) => { resolve = settle })
+  return { promise, resolve }
+}
+
+async function harness() {
+  const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-')))
+  const ctx = new Context()
+  roots.push(ctx)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(Storage)
+  ctx.storage.backend.register('memory', new MemoryStorageBackend())
+  const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
+  ctx.storage.mount('domain', storageDomain)
+  ctx.provide('storageDomain', storageDomain)
+  ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
+  await ctx.plugin(WorkspaceRegistry)
+  const dispose = (): void => {}
+  ctx.provide('typert', {
+    lookups: { configure: () => dispose },
+    contexts: { configureHost: () => dispose },
+  } as never)
+  const controller = new WorkspaceController(ctx)
+  return { controller, ctx, root, storageDomain }
+}
+
+function stageDir(root: string, name: string): string {
+  const path = join(root, name)
+  mkdirSync(path, { recursive: true })
+  return path
+}
+
+async function nextFrame(
+  iterator: AsyncIterator<WorkspaceFollowFrame>,
+): Promise<WorkspaceFollowFrame> {
+  const next = await iterator.next()
+  if (next.done === true) throw new Error('Workspace stream ended before the expected frame')
+  return next.value
+}
+
+describe('WorkspaceController commands', () => {
+  it('serializes concurrent path adoption and preserves an existing title', async () => {
+    const { controller, root } = await harness()
+    const path = stageDir(root, 'alpha')
+    const results = await Promise.all([
+      controller.create({ path }),
+      controller.create({ path }),
+    ])
+    const created = results.find(result => result.created)
+    const resolved = results.find(result => !result.created)
+    expect(created).toMatchObject({ workspace: { path, title: 'alpha' } })
+    expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
+
+    const workspaceId = created?.workspace.workspaceId
+    if (workspaceId === undefined) throw new Error('fixture did not create a Workspace')
+    await controller.rename({ workspaceId, title: 'renamed' })
+    await expect(controller.create({ path })).resolves.toMatchObject({
+      created: false,
+      workspace: { workspaceId, title: 'renamed' },
+    })
+  })
+
+  it('maps invalid paths, blank names, conflicts, and unknown ids to stable failures', async () => {
+    const { controller, root } = await harness()
+    const first = await controller.create({ path: stageDir(root, 'first') })
+    const second = await controller.create({ path: stageDir(root, 'second') })
+
+    await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({
+      failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } },
+    })
+    expect(existsSync(join(root, 'missing'))).toBe(false)
+    await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: '  ' }))
+      .rejects.toMatchObject({ failure: { code: 'bad-request' } })
+    await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' })
+    await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' }))
+      .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } })
+    await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId }))
+      .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+  })
+
+  it('preserves Remote failures and propagates unexpected registry failures', async () => {
+    const { controller, ctx, root } = await harness()
+    const remoteFailure = new TypertRemoteFailure({
+      code: 'fixture-failure',
+      message: 'already mapped',
+      details: {},
+    })
+    const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
+      .mockRejectedValueOnce(remoteFailure)
+      .mockRejectedValueOnce('plain failure')
+    await expect(controller.create({ path: stageDir(root, 'remote-failure') }))
+      .rejects.toBe(remoteFailure)
+    const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') })
+    await expect(plainFailure).rejects.toMatchObject({
+      failure: { code: 'workspace-invalid-path' },
+    })
+    await expect(plainFailure).rejects.toThrow('plain failure')
+    resolveByPath.mockRestore()
+
+    const created = await controller.create({ path: stageDir(root, 'created') })
+    const workspace = ctx.workspaceRegistry.get(created.workspace.workspaceId)
+    if (workspace === undefined) throw new Error('fixture Workspace disappeared')
+
+    const orderFailure = new Error('order storage failed')
+    vi.spyOn(ctx.workspaceRegistry, 'insertBefore').mockRejectedValueOnce(orderFailure)
+    await expect(controller.insertBefore({ workspaceId: created.workspace.workspaceId }))
+      .rejects.toBe(orderFailure)
+
+    const moveFailure = new Error('membership storage failed')
+    vi.spyOn(workspace, 'insertSessionBefore').mockRejectedValueOnce(moveFailure)
+    await expect(controller.insertSessionBefore({
+      workspaceId: created.workspace.workspaceId,
+      sessionId: SessionId('session'),
+    })).rejects.toBe(moveFailure)
+
+    const archiveFailure = new Error('archive storage failed')
+    vi.spyOn(ctx.workspaceRegistry, 'archiveSession').mockRejectedValueOnce(archiveFailure)
+    await expect(controller.archiveSession({ sessionId: SessionId('session') }))
+      .rejects.toBe(archiveFailure)
+  })
+
+  it('resolves queued Workspace identities when their operation starts', async () => {
+    const { controller, ctx, root } = await harness()
+    const target = await controller.create({ path: stageDir(root, 'target') })
+    const blockerPath = stageDir(root, 'blocker')
+    const gate = deferred<undefined>()
+    const originalResolveByPath = ctx.workspaceRegistry.resolveByPath.bind(ctx.workspaceRegistry)
+    const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath')
+    resolveByPath.mockImplementationOnce(async (path) => {
+      await gate.promise
+      return originalResolveByPath(path)
+    })
+
+    const blocker = controller.create({ path: blockerPath })
+    const deletion = controller.delete({ workspaceId: target.workspace.workspaceId })
+    const staleRename = controller.rename({
+      workspaceId: target.workspace.workspaceId,
+      title: 'must-not-land',
+    })
+    gate.resolve(undefined)
+    await blocker
+    await expect(deletion).resolves.toEqual({ deleted: true })
+    await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+  })
+
+  it('reorders Workspaces and Sessions and archives only known Sessions', async () => {
+    const { controller, ctx, root } = await harness()
+    const first = await controller.create({ path: stageDir(root, 'first') })
+    const second = await controller.create({ path: stageDir(root, 'second') })
+    await expect(controller.insertBefore({
+      workspaceId: first.workspace.workspaceId,
+      beforeWorkspaceId: second.workspace.workspaceId,
+    })).resolves.toEqual({
+      workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
+    })
+    await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId }))
+      .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+
+    const session = ctx.sessions.create(SessionId('session-one'), {
+      meta: { cwd: first.workspace.path },
+    })
+    const workspace = ctx.workspaceRegistry.get(first.workspace.workspaceId)
+    if (workspace === undefined) throw new Error('fixture Workspace disappeared')
+    await workspace.attachSession(session.id)
+    await expect(controller.insertSessionBefore({
+      workspaceId: first.workspace.workspaceId,
+      sessionId: session.id,
+    })).resolves.toMatchObject({ workspace: { sessionIds: [session.id] } })
+    await expect(controller.insertSessionBefore({
+      workspaceId: first.workspace.workspaceId,
+      sessionId: SessionId('missing-session'),
+    })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } })
+    await expect(controller.insertSessionBefore({
+      workspaceId: first.workspace.workspaceId,
+      sessionId: session.id,
+      beforeSessionId: SessionId('missing-anchor'),
+    })).rejects.toMatchObject({
+      failure: {
+        code: 'workspace-move-invalid',
+        details: { beforeSessionId: 'missing-anchor' },
+      },
+    })
+    await expect(controller.insertSessionBefore({
+      workspaceId: 'missing' as WorkspaceId,
+      sessionId: session.id,
+    })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } })
+
+    await expect(controller.archiveSession({ sessionId: session.id }))
+      .resolves.toEqual({ archivedSessionIds: [session.id] })
+    await expect(controller.archiveSession({ sessionId: SessionId('unknown') }))
+      .rejects.toMatchObject({ failure: { code: 'session-not-found' } })
+  })
+})
+
+describe('WorkspaceController follow', () => {
+  it('seeds a new feed from existing rows and rejects an inconsistent registry commit', async () => {
+    const { ctx, root } = await harness()
+    const existing = await ctx.workspaceRegistry.create(stageDir(root, 'existing'))
+    const feed = new WorkspaceFeed(ctx)
+    expect(feed.baseline()).toMatchObject({
+      items: [{ workspaceId: existing.id }],
+    })
+
+    expect(() => {
+      ctx.emit('domain/changed', {
+        domain: 'workspace',
+        table: '',
+        key: '',
+        operation: 'put',
+        value: {
+          initialized: true,
+          workspaceIds: ['missing'],
+          archivedSessionIds: [],
+        },
+      })
+    }).toThrow('references missing Workspace "missing"')
+  })
+
+  it('starts with a complete baseline and emits committed increments in domain order', async () => {
+    const { controller, ctx, root } = await harness()
+    const abort = new AbortController()
+    const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'baseline',
+      value: { items: [], archivedSessionIds: [] },
+    })
+
+    const first = await controller.create({ path: stageDir(root, 'first') })
+    await expect(nextFrame(iterator)).resolves.toMatchObject({
+      type: 'upsert', workspace: { workspaceId: first.workspace.workspaceId },
+    })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'order', workspaceIds: [first.workspace.workspaceId],
+    })
+    await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'renamed' })
+    await expect(nextFrame(iterator)).resolves.toMatchObject({
+      type: 'upsert', workspace: { title: 'renamed' },
+    })
+
+    const second = await controller.create({ path: stageDir(root, 'second') })
+    await expect(nextFrame(iterator)).resolves.toMatchObject({
+      type: 'upsert', workspace: { workspaceId: second.workspace.workspaceId },
+    })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'order', workspaceIds: [second.workspace.workspaceId, first.workspace.workspaceId],
+    })
+    await controller.insertBefore({
+      workspaceId: first.workspace.workspaceId,
+      beforeWorkspaceId: second.workspace.workspaceId,
+    })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'order',
+      workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId],
+    })
+
+    const session = ctx.sessions.create(SessionId('archived'), {
+      meta: { cwd: first.workspace.path },
+    })
+    await controller.archiveSession({ sessionId: session.id })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'archived', archivedSessionIds: [session.id],
+    })
+    await controller.delete({ workspaceId: second.workspace.workspaceId })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'order', workspaceIds: [first.workspace.workspaceId],
+    })
+    await expect(nextFrame(iterator)).resolves.toEqual({
+      type: 'remove', workspaceId: second.workspace.workspaceId,
+    })
+
+    abort.abort()
+    await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
+  })
+
+  it('ignores unrelated domain writes and closes active followers on disposal', async () => {
+    const { controller, ctx, root } = await harness()
+    const abort = new AbortController()
+    const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
+    await nextFrame(iterator)
+    ctx.emit('domain/changed', {
+      domain: 'other', table: 'records', key: 'x', operation: 'put', value: {},
+    })
+    ctx.emit('domain/changed', {
+      domain: 'workspace', table: '', key: '', operation: 'deleted',
+    })
+    ctx.emit('domain/changed', {
+      domain: 'workspace', table: 'other', key: 'x', operation: 'put', value: {},
+    })
+    ctx.emit('domain/changed', {
+      domain: 'workspace', table: 'workspaces', key: 'unknown', operation: 'deleted',
+    })
+    const pending = iterator.next()
+    const created = await controller.create({ path: stageDir(root, 'visible') })
+    await expect(pending).resolves.toMatchObject({ value: { type: 'upsert' } })
+    await expect(iterator.next()).resolves.toEqual({
+      done: false,
+      value: { type: 'order', workspaceIds: [created.workspace.workspaceId] },
+    })
+
+    const closing = iterator.next()
+    await ctx.fiber.dispose()
+    roots.splice(roots.indexOf(ctx), 1)
+    await expect(closing).resolves.toEqual({ done: true, value: undefined })
+  })
+})

+ 0 - 425
packages/client/runtime/src/client/workspaces/manager.ts

@@ -1,425 +0,0 @@
-/** Workspace baseline, incremental-frame, and unary-action owner. */
-
-import type {
-  HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-api-remotes/client'
-import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { Notifier } from '../sessions/notifier.ts'
-import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
-
-/** Monotone workspace-list arrival lifecycle. */
-export type WorkspaceListPhase = 'pending' | 'ready'
-
-/** Immutable workspace-list snapshot. */
-export interface WorkspaceListSnapshot {
-  items: readonly WorkspaceView[]
-  /**
-   * Registry-global archive set in Host order (hidden from grouping
-   * surfaces; accounting slots retained). A plain array, not a Set: public
-   * snapshot state stays in the store engine's plain-data vocabulary
-   * (immer drafts reject Sets without the MapSet plugin); membership
-   * lookups build their own transient Set where they need one.
-   */
-  archivedSessionIds: readonly SessionId[]
-  state: 'idle' | 'loading' | 'error'
-  phase: WorkspaceListPhase
-  error: RpcError | null
-}
-
-type WorkspaceDelta =
-  | { type: 'upsert'; workspace: WorkspaceView }
-  | { type: 'remove'; workspaceId: WorkspaceId }
-  | { type: 'order'; workspaceIds: readonly WorkspaceId[] }
-
-/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
-export class WorkspaceManager {
-  private items: Workspace[] = []
-  private itemViewsSource: readonly Workspace[] | null = null
-  private itemViewsCache: readonly WorkspaceView[] = []
-  // Full-snapshot state (list response / unary response / changed frame all
-  // carry the complete set), so deltas never merge — installs replace.
-  private archivedSessionIds: readonly SessionId[] = []
-  private state: WorkspaceListSnapshot['state'] = 'idle'
-  private phase: WorkspaceListPhase = 'pending'
-  private error: RpcError | null = null
-  private inflight: Promise<void> | null = null
-  private refreshFrames: WorkspaceDelta[] | null = null
-  /**
-   * True once a frame or unary echo installed the archive set while a list
-   * request was in flight: that install is newer than the pending baseline,
-   * so the baseline's (older) set must not roll it back — the archive
-   * mirror of replaying refreshFrames over the item baseline.
-   */
-  private archivedSupersedesRefresh = false
-  /** Latest local reorder request; only its unary echo may install order. */
-  private orderRequestGeneration = 0
-  /** Increments on order frames so a later remote commit outranks an older unary echo. */
-  private orderFrameGeneration = 0
-  /** Last complete order accepted from a Host baseline, frame, or current unary echo. */
-  private committedOrder: WorkspaceId[] = []
-  /**
-   * Ids this process has seen removed, kept for the connection's lifetime so
-   * a late changed frame or a stale baseline row cannot resurrect a deleted
-   * row. Correctness rests on Host ids never being reused (the registry mints
-   * a fresh `randomUUID` per record, including when the same directory is
-   * registered again) — a path-derived id scheme would turn these entries
-   * into permanent blindfolds and must clear them instead.
-   */
-  private readonly removedIds = new Set<WorkspaceId>()
-  private snapshotCache: WorkspaceListSnapshot
-  private readonly notifier = new Notifier(() => {
-    this.snapshotCache = this.buildSnapshot()
-  })
-
-  /** @param api - shared wire client. */
-  constructor(private readonly api: IApiClient) {
-    this.snapshotCache = this.buildSnapshot()
-  }
-
-  /**
-   * Refresh from workspace.list. The first successful response establishes
-   * Host order; later responses re-establish the durable order so reconnects
-   * adopt reorders committed while this client was offline. Frames arriving
-   * during the RPC are replayed over its response.
-   * @returns the shared in-flight refresh.
-   */
-  refresh(): Promise<void> {
-    if (this.inflight !== null) return this.inflight
-    this.state = 'loading'
-    this.error = null
-    const frames: WorkspaceDelta[] = []
-    this.refreshFrames = frames
-    this.notifier.markDirty()
-    this.inflight = (async () => {
-      try {
-        const { result } = await this.api.workspace.list({})
-        if (result.ok) {
-          let items = result.value.items
-          items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
-          for (const delta of frames) items = applyWorkspaceDelta(items, delta)
-          this.installViews(items)
-          if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
-          this.state = 'idle'
-          this.phase = 'ready'
-        } else {
-          this.state = 'error'
-          this.error = result.error
-        }
-      } catch (error) {
-        this.state = 'error'
-        const folded = transportError<never>(error)
-        /* v8 ignore next -- transportError always returns the failure branch. */
-        this.error = folded.ok ? null : folded.error
-      } finally {
-        this.refreshFrames = null
-        this.archivedSupersedesRefresh = false
-        this.inflight = null
-        this.notifier.markDirty()
-      }
-    })()
-    return this.inflight
-  }
-
-  /**
-   * Create or resolve a real Workspace, then publish its returned snapshot
-   * without waiting for the changed frame.
-   * @param input - the existing absolute path to adopt.
-   * @returns the wire result.
-   */
-  async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
-    const workspace = new Workspace(this.api, input)
-    const completion = workspace.materialize()
-    if (completion === undefined) throw new Error('a local Workspace must be materializable')
-    const result = await completion
-    if (result.ok) this.upsert(result.value.workspace, workspace)
-    return result
-  }
-
-  /**
-   * Rename a Workspace, then publish its returned snapshot without waiting
-   * for the changed frame.
-   * @param workspaceId - target workspace.
-   * @param title - new display title.
-   * @returns the wire result.
-   */
-  async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
-    const { result } = await this.api.workspace.rename({ workspaceId, title })
-    if (result.ok) this.upsert(result.value.workspace)
-    return result
-  }
-
-  /**
-   * Delete a Workspace registration and remove its local projection from the
-   * unary response without waiting for the Host frame.
-   * @param workspaceId - target workspace.
-   * @returns the wire result.
-   */
-  async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
-    const { result } = await this.api.workspace.delete({ workspaceId })
-    if (result.ok) this.remove(workspaceId, true)
-    return result
-  }
-
-  /**
-   * Move a Workspace within the registry display order and install the full
-   * returned order without waiting for the Host frame.
-   * @param workspaceId - Workspace to move.
-   * @param beforeWorkspaceId - Anchor workspace; omitted appends.
-   * @returns the wire result.
-   */
-  async insertBefore(
-    workspaceId: WorkspaceId,
-    beforeWorkspaceId?: WorkspaceId,
-  ): Promise<RpcResult<{ workspaceIds: WorkspaceId[] }>> {
-    const requestGeneration = ++this.orderRequestGeneration
-    const frameGeneration = this.orderFrameGeneration
-    const localOrder = this.itemViews().map(workspace => workspace.workspaceId)
-    this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
-    let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
-    try {
-      ;({ result } = await this.api.workspace.insertBefore({
-        workspaceId,
-        ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId },
-      }))
-    } catch (error) {
-      if (requestGeneration === this.orderRequestGeneration
-        && frameGeneration === this.orderFrameGeneration) {
-        this.installOrder(this.committedOrder)
-      }
-      throw error
-    }
-    if (result.ok && requestGeneration === this.orderRequestGeneration
-      && frameGeneration === this.orderFrameGeneration) {
-      this.installOrder(result.value.workspaceIds, true)
-    } else if (!result.ok && requestGeneration === this.orderRequestGeneration
-      && frameGeneration === this.orderFrameGeneration) {
-      this.installOrder(this.committedOrder)
-    }
-    return result
-  }
-
-  /**
-   * Move a session within its Workspace's manual order, then publish the
-   * returned snapshot without waiting for the changed frame.
-   * @param workspaceId - owning workspace.
-   * @param sessionId - accounted session to move.
-   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
-   * @returns the wire result.
-   */
-  async insertSessionBefore(
-    workspaceId: WorkspaceId,
-    sessionId: SessionId,
-    beforeSessionId?: SessionId,
-  ): Promise<RpcResult<{ workspace: WorkspaceView }>> {
-    const { result } = await this.api.workspace.insertSessionBefore({
-      workspaceId, sessionId,
-      ...beforeSessionId === undefined ? {} : { beforeSessionId },
-    })
-    if (result.ok) this.upsert(result.value.workspace)
-    return result
-  }
-
-  /**
-   * Archive one session in the registry-global set, then install the
-   * returned full set without waiting for the changed frame.
-   * @param sessionId - session to archive.
-   * @returns the wire result.
-   */
-  async archiveSession(sessionId: SessionId): Promise<RpcResult<{ archivedSessionIds: SessionId[] }>> {
-    const { result } = await this.api.workspace.archiveSession({ sessionId })
-    if (result.ok) this.installArchived(result.value.archivedSessionIds)
-    return result
-  }
-
-  /**
-   * Host-frame entry. Non-workspace frames are ignored so the runtime can
-   * fan one host stream out to both object managers.
-   * @param envelope - host stream envelope.
-   */
-  handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
-    if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
-    else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
-    else if (envelope.payload.type === 'host/workspace-order-changed') {
-      this.orderFrameGeneration++
-      this.installOrder(envelope.payload.workspaceIds, true)
-    }
-    else if (envelope.payload.type === 'host/archived-sessions-changed') {
-      this.installArchived(envelope.payload.archivedSessionIds)
-    }
-  }
-
-  /** Re-pull the baseline after each connection generation. */
-  handleConnected(): void {
-    void this.refresh()
-  }
-
-  /**
-   * Subscribe to workspace snapshot invalidation.
-   * @param listener - snapshot invalidation callback.
-   * @returns unsubscribe function.
-   */
-  subscribe(listener: () => void): () => void {
-    return this.notifier.subscribe(listener)
-  }
-
-  /**
-   * Read the cached workspace snapshot after flushing pending notifications.
-   * @returns the cached workspace snapshot.
-   */
-  getSnapshot(): WorkspaceListSnapshot {
-    this.notifier.ensureFresh()
-    return this.snapshotCache
-  }
-
-  private buildSnapshot(): WorkspaceListSnapshot {
-    return {
-      items: this.itemViews(),
-      archivedSessionIds: this.archivedSessionIds,
-      state: this.state,
-      phase: this.phase,
-      error: this.error,
-    }
-  }
-
-  /**
-   * Replace the archive set when membership actually changed (array identity
-   * backs Object.is short-circuits). Host snapshots are append-ordered, so
-   * positional comparison is exact, not merely heuristic.
-   */
-  private installArchived(archivedSessionIds: readonly SessionId[]): void {
-    if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
-    if (archivedSessionIds.length === this.archivedSessionIds.length
-      && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
-    this.archivedSessionIds = [...archivedSessionIds]
-    this.notifier.markDirty()
-  }
-
-  /** Reorder known Workspace objects, optionally recording a Host-committed sequence. */
-  private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
-    if (committed) {
-      this.refreshFrames?.push({ type: 'order', workspaceIds })
-      this.committedOrder = [...workspaceIds]
-    }
-    const rank = new Map(workspaceIds.map((id, index) => [id, index]))
-    const items = [...this.items].sort((left, right) => {
-      const leftId = left.getSnapshot().view?.workspaceId
-      const rightId = right.getSnapshot().view?.workspaceId
-      return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER)
-        - (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER)
-    })
-    if (items.every((item, index) => item === this.items[index])) return
-    this.items = items
-    this.notifier.markDirty()
-  }
-
-  /** Upsert one Host view, optionally retaining the local object that materialized it. */
-  private upsert(view: WorkspaceView, identity?: Workspace): void {
-    if (this.removedIds.has(view.workspaceId)) return
-    this.refreshFrames?.push({ type: 'upsert', workspace: view })
-    const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
-    // Mutation responses and changed frames race (two carriers, no ordering):
-    // reject a snapshot strictly older than the installed projection so a
-    // late unary response cannot roll back a newer frame.
-    const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
-    if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
-    if (!this.committedOrder.includes(view.workspaceId)) {
-      this.committedOrder = [view.workspaceId, ...this.committedOrder]
-    }
-    if (identity !== undefined) {
-      this.items = index === -1
-        ? [identity, ...this.items]
-        : this.items.map((item, position) => position === index ? identity : item)
-    } else if (index === -1) {
-      this.items = [new Workspace(this.api, view), ...this.items]
-    } else {
-      this.items[index]?.adopt(view)
-      this.items = [...this.items]
-    }
-    this.notifier.markDirty()
-  }
-
-  /** Remove one id idempotently and retain a tombstone against late echoes. */
-  private remove(workspaceId: WorkspaceId, direct = false): void {
-    this.refreshFrames?.push({ type: 'remove', workspaceId })
-    this.removedIds.add(workspaceId)
-    this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
-    const items = this.items.filter(item =>
-      item.getSnapshot().view?.workspaceId !== workspaceId)
-    if (items.length === this.items.length) {
-      // The Host frame may have removed the row first but left its batched
-      // notification pending. A successful unary echo still flushes that
-      // committed state before the user action resolves.
-      if (direct) this.notifier.notifyNow()
-      return
-    }
-    this.items = items
-    if (direct) this.notifier.notifyNow()
-    else this.notifier.markDirty()
-  }
-
-  private installViews(views: readonly WorkspaceView[]): void {
-    const existing = new Map(
-      this.items.flatMap((workspace) => {
-        const view = workspace.getSnapshot().view
-        return view === undefined ? [] : [[view.workspaceId, workspace] as const]
-      }),
-    )
-    const installed = new Map<WorkspaceView['workspaceId'], Workspace>()
-    for (const view of views) {
-      const duplicate = installed.get(view.workspaceId)
-      if (duplicate !== undefined) {
-        duplicate.adopt(view)
-        continue
-      }
-      const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view)
-      workspace.adopt(view)
-      installed.set(view.workspaceId, workspace)
-    }
-    this.items = [...installed.values()]
-    this.committedOrder = views.map(view => view.workspaceId)
-  }
-
-  private itemViews(): readonly WorkspaceView[] {
-    if (this.itemViewsSource === this.items) return this.itemViewsCache
-    this.itemViewsSource = this.items
-    this.itemViewsCache = this.items.flatMap((workspace) => {
-      const view = workspace.getSnapshot().view
-      return view === undefined ? [] : [view]
-    })
-    return this.itemViewsCache
-  }
-}
-
-/** Known ids retain their position; a newly created Workspace enters first. */
-function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] {
-  const index = items.findIndex(item => item.workspaceId === workspace.workspaceId)
-  return index === -1
-    ? [workspace, ...items]
-    : items.map((item, position) => position === index ? workspace : item)
-}
-
-/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
-function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
-  if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace)
-  if (delta.type === 'remove') {
-    return items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
-  }
-  const rank = new Map(delta.workspaceIds.map((id, index) => [id, index]))
-  return [...items].sort((left, right) =>
-    (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER)
-    - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER))
-}
-
-/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */
-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)]
-}

+ 25 - 40
packages/client/runtime/src/client/workspaces/service.ts

@@ -1,15 +1,18 @@
-/** WorkspaceRuntime projects the Workspace object manager for UI consumers. */
+/** WorkspaceRuntime combines controller-owned Workspace state with Session/UI behavior. */
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {
   DirectoryListing, IApiClient, RpcError,
   SessionId, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+  ClientWorkspaceModel, WorkspaceListPhase,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type { SnapshotStore } from '../contract/store.ts'
 import { createSnapshotStore } from '../contract/store.ts'
 import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
 import type { IWorkspaces } from '../contract/workspaces.ts'
-import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
 
 /** Workspace list plus the two-baseline readiness and default-target projection. */
 export interface WorkspaceListState {
@@ -24,8 +27,8 @@ export interface WorkspaceListState {
   archivedSessionIds: readonly SessionId[]
   state: 'idle' | 'loading' | 'error'
   phase: WorkspaceListPhase
-  error: RpcError | null
-  /** True only after both workspace.list and session.list have succeeded. */
+  error: RemoteFailure | null
+  /** True only after both Workspace and Session stream baselines have arrived. */
   baselinesReady: boolean
   /** Most recently active Workspace, derived without changing `items` order. */
   recentWorkspaceId: WorkspaceId | undefined
@@ -33,7 +36,7 @@ export interface WorkspaceListState {
 
 /** Structured create failure for UI flows that distinguish Host business errors. */
 export class WorkspaceCreateError extends Error {
-  constructor(readonly rpcError: RpcError) {
+  constructor(readonly rpcError: RemoteFailure) {
     super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
     this.name = 'WorkspaceCreateError'
   }
@@ -49,10 +52,8 @@ export class DirectoryBrowseError extends Error {
 
 /** Real Workspace object layer and Host actions. */
 export class WorkspaceRuntime implements IWorkspaces {
-  /** UI-facing immutable projection; the manager remains wire truth. */
+  /** UI-facing projection derived from the controller model and Session list. */
   readonly list: SnapshotStore<WorkspaceListState>
-  /** Workspace baseline and frame owner. */
-  private readonly manager: WorkspaceManager
   /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
   private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
   /** Guards the runtime-owned one-shot initial-selection subscription. */
@@ -61,15 +62,20 @@ export class WorkspaceRuntime implements IWorkspaces {
   /**
    * @param ctx - client root context.
    * @param api - shared wire client.
+   * @param model - Workspace Controller's Client state model.
    * @param sessions - cross-domain sessions face used for recency and blank-session reuse.
    */
-  constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
-    this.manager = new WorkspaceManager(api)
+  constructor(
+    ctx: Context,
+    private readonly api: IApiClient,
+    private readonly model: ClientWorkspaceModel,
+    private readonly sessions: SessionsPort,
+  ) {
     this.list = createSnapshotStore<WorkspaceListState>({
-      items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
+      items: [], archivedSessionIds: [], state: 'loading', phase: 'pending', error: null,
       baselinesReady: false, recentWorkspaceId: undefined,
     })
-    this.manager.subscribe(() => { this.project() })
+    this.model.subscribe(() => { this.project() })
     this.sessions.list.subscribe(() => { this.project() })
     ctx.reflect.provide('workspaces', this, undefined)
   }
@@ -197,7 +203,7 @@ export class WorkspaceRuntime implements IWorkspaces {
    * @returns the created or idempotently resolved Workspace.
    */
   async create(input: { path: string }): Promise<WorkspaceView> {
-    const result = await this.manager.create(input)
+    const result = await this.model.create(input)
     if (!result.ok) throw new WorkspaceCreateError(result.error)
     return result.value.workspace
   }
@@ -256,7 +262,7 @@ export class WorkspaceRuntime implements IWorkspaces {
    * @returns the renamed Workspace view.
    */
   async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
-    const result = await this.manager.rename(workspaceId, title)
+    const result = await this.model.rename(workspaceId, title)
     if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
     return result.value.workspace
   }
@@ -267,7 +273,7 @@ export class WorkspaceRuntime implements IWorkspaces {
    * @param workspaceId - target workspace.
    */
   async delete(workspaceId: WorkspaceId): Promise<void> {
-    const result = await this.manager.delete(workspaceId)
+    const result = await this.model.delete(workspaceId)
     if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
   }
 
@@ -277,7 +283,7 @@ export class WorkspaceRuntime implements IWorkspaces {
    * @param beforeWorkspaceId - Anchor workspace; omitted appends.
    */
   async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
-    const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId)
+    const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId)
     if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
   }
 
@@ -288,7 +294,7 @@ export class WorkspaceRuntime implements IWorkspaces {
    * @param sessionId - session to archive.
    */
   async archiveSession(sessionId: SessionId): Promise<void> {
-    const result = await this.manager.archiveSession(sessionId)
+    const result = await this.model.archiveSession(sessionId)
     if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
   }
 
@@ -304,34 +310,13 @@ export class WorkspaceRuntime implements IWorkspaces {
     sessionId: SessionId,
     beforeSessionId?: SessionId,
   ): Promise<WorkspaceView> {
-    const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
+    const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
     return result.value.workspace
   }
 
-  /**
-   * Refresh the workspace baseline, reusing an in-flight pull.
-   * @returns completion of the current or newly started workspace baseline pull.
-   */
-  refresh(): Promise<void> {
-    return this.manager.refresh()
-  }
-
-  /**
-   * Route a Host stream envelope into the Workspace object layer.
-   * @param envelope - validated Host stream envelope.
-   */
-  handleHostEnvelope(envelope: Parameters<WorkspaceManager['handleHostEnvelope']>[0]): void {
-    this.manager.handleHostEnvelope(envelope)
-  }
-
-  /** Rebuild the Workspace baseline after connection. */
-  handleConnected(): void {
-    this.manager.handleConnected()
-  }
-
   private project(): void {
-    const workspace = this.manager.getSnapshot()
+    const workspace = this.model.getSnapshot()
     const sessions = this.sessions.list.getSnapshot()
     const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
     // An archived current selection clears into the New Session view state —

+ 0 - 142
packages/client/runtime/src/client/workspaces/workspace.ts

@@ -1,142 +0,0 @@
-/** React-free Workspace entity with a client-local materialization lifecycle. */
-
-import type {
-  IApiClient, RpcResult, WorkspaceView,
-} from '@deepseek-ai/dsh-api-remotes/client'
-import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type { ObservableSnapshot } from '../contract/store.ts'
-import { Notifier } from '../sessions/notifier.ts'
-
-/** Host input retained by a local Workspace until materialization succeeds. */
-export type WorkspaceCreateInput = { path: string }
-
-/** Observable state of a client-local Workspace intent. */
-export interface WorkspaceIntentSnapshot {
-  name: string
-  phase: 'ready' | 'creating'
-  error?: string
-}
-
-/** A Workspace is either a local intent or a materialized Host view. */
-export interface WorkspaceSnapshot {
-  view: WorkspaceView | undefined
-  intent: WorkspaceIntentSnapshot | undefined
-}
-
-interface WorkspaceIntent {
-  input: WorkspaceCreateInput
-  snapshot: WorkspaceIntentSnapshot
-}
-
-/**
- * Observable Workspace object whose identity survives Host materialization.
- * Local instances retain their create input and failure state; materialized
- * instances expose the latest Host view.
- */
-export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
-  private view: WorkspaceView | undefined
-  private intent: WorkspaceIntent | undefined
-  private materialization: Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | null = null
-  private snapshotCache: WorkspaceSnapshot
-  private readonly notifier = new Notifier(() => {
-    this.snapshotCache = this.buildSnapshot()
-  })
-
-  /**
-   * @param api - shared wire client.
-   * @param source - local create input or an existing Host Workspace view.
-   */
-  constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) {
-    if ('workspaceId' in source) {
-      this.view = source
-    } else {
-      this.intent = {
-        input: source,
-        snapshot: { name: intentName(source), phase: 'ready' },
-      }
-    }
-    this.snapshotCache = this.buildSnapshot()
-  }
-
-  /**
-   * Materialize this local Workspace through the Host create API.
-   * Re-entry shares the in-flight completion; a materialized instance returns undefined.
-   * @returns the Host result, or undefined when this Workspace is already materialized.
-   */
-  materialize(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | undefined {
-    if (this.materialization !== null) return this.materialization
-    const intent = this.intent
-    if (intent === undefined) return undefined
-    intent.snapshot = { name: intent.snapshot.name, phase: 'creating' }
-    this.notifier.notifyNow()
-    const completion = this.completeMaterialization(intent).finally(() => {
-      if (this.materialization === completion) this.materialization = null
-    })
-    this.materialization = completion
-    return completion
-  }
-
-  /**
-   * Adopt a Host view without replacing this Workspace object.
-   * An existing materialized identity accepts updates only for the same Workspace id.
-   * @param view - latest Host projection.
-   */
-  adopt(view: WorkspaceView): void {
-    if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) {
-      throw new Error('cannot adopt a different Workspace id')
-    }
-    this.view = view
-    this.intent = undefined
-    this.notifier.markDirty()
-  }
-
-  /**
-   * Subscribe to Workspace snapshot invalidation.
-   * @param listener - snapshot invalidation callback.
-   * @returns unsubscribe function.
-   */
-  subscribe(listener: () => void): () => void {
-    return this.notifier.subscribe(listener)
-  }
-
-  /**
-   * Read the cached Workspace snapshot after flushing pending notifications.
-   * @returns the cached Workspace snapshot.
-   */
-  getSnapshot(): WorkspaceSnapshot {
-    this.notifier.ensureFresh()
-    return this.snapshotCache
-  }
-
-  private async completeMaterialization(
-    intent: WorkspaceIntent,
-  ): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
-    let result: RpcResult<{ workspace: WorkspaceView; created: boolean }>
-    try {
-      result = (await this.api.workspace.create(intent.input)).result
-    } catch (error) {
-      result = transportError(error)
-    }
-    if (this.intent !== intent) return result
-    if (result.ok) {
-      this.adopt(result.value.workspace)
-    } else {
-      intent.snapshot = {
-        name: intent.snapshot.name,
-        phase: 'ready',
-        error: `${result.error.code}: ${result.error.message}`,
-      }
-      this.notifier.markDirty()
-    }
-    return result
-  }
-
-  private buildSnapshot(): WorkspaceSnapshot {
-    return { view: this.view, intent: this.intent?.snapshot }
-  }
-}
-
-function intentName(input: WorkspaceCreateInput): string {
-  const trimmed = input.path.replace(/[\\/]+$/, '')
-  return trimmed.split(/[\\/]/).pop() ?? input.path
-}

+ 106 - 274
packages/client/runtime/tests/workspaces-service.client.spec.ts

@@ -1,10 +1,12 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
+import { ClientWorkspaceModel } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import { SessionRuntime } from '../src/client/sessions/service.ts'
-import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
 import { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from '../src/client/workspaces/service.ts'
-import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
+import {
+  FakeApiClient, err, fakeRemote, ok, remoteOk, workspaceErr,
+} from './fake-api.client.ts'
 
 const sid = (id: string): SessionId => id as SessionId
 const wid = (id: string): WorkspaceId => id as WorkspaceId
@@ -16,191 +18,49 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0
   }
 }
 
-describe('WorkspaceManager', () => {
-  it('replays changed frames over hydration and adopts the durable order on refresh', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
-    api.onWorkspaceList = () => gate.promise
-    const manager = new WorkspaceManager(api)
-    const hydration = manager.refresh()
-    manager.handleHostEnvelope({
-      rpcId: 'changed' as never,
-      payload: { type: 'host/workspace-changed', workspace: workspace('new') },
-    })
-    gate.resolve(ok({ items: [workspace('old')] as never[] }))
-    await hydration
-    expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
-
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('old'), workspace('new')] as never[],
-    }))
-    await manager.refresh()
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new'])
-  })
-
-  it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
-    api.onWorkspaceList = () => gate.promise
-    const manager = new WorkspaceManager(api)
-    const first = manager.refresh()
-    const second = manager.refresh()
-    expect(manager.getSnapshot().state).toBe('loading')
-    gate.resolve(ok({ items: [] }))
-    await Promise.all([first, second])
-    expect(api.callsOf('workspace.list')).toHaveLength(1)
-
-    api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
-    await manager.refresh()
-    expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
-    api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
-    await manager.refresh()
-    expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
-  })
-
-  it('creates by path, prepends a new row, and folds failures', async () => {
-    const api = new FakeApiClient()
-    const manager = new WorkspaceManager(api)
-    api.onWorkspaceCreate = payload => Promise.resolve(ok({
-      workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
-      created: true,
-      payload,
-    } as never))
-    await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
-    expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
-    expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
-
-    api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
-    await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
-      ok: false, error: { code: 'internal', message: 'create transport' },
-    })
-  })
-
-  it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => {
-    const api = new FakeApiClient()
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('one'), workspace('two'), workspace('three')] as never[],
-    }))
-    const manager = new WorkspaceManager(api)
-    await manager.refresh()
-
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
-    api.onWorkspaceInsertBefore = () => gate.promise
-    const pending = manager.insertBefore(wid('three'), wid('one'))
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
-    manager.handleHostEnvelope({
-      rpcId: 'newer-order' as never,
-      payload: {
-        type: 'host/workspace-order-changed',
-        workspaceIds: [wid('one'), wid('three'), wid('two')],
-      },
-    })
-    gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] }))
-    await pending
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
-
-    api.onWorkspaceInsertBefore = () => Promise.resolve(err({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' },
-    }))
-    const rejected = manager.insertBefore(wid('three'))
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
-    await expect(rejected).resolves.toMatchObject({ ok: false })
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
-
-    api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down'))
-    const disconnected = manager.insertBefore(wid('three'), wid('one'))
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two'])
-    await expect(disconnected).rejects.toThrow('transport down')
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
-  })
-
-  it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => {
-    const api = new FakeApiClient()
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('one'), workspace('two'), workspace('three')] as never[],
-    }))
-    const manager = new WorkspaceManager(api)
-    await manager.refresh()
-    const firstGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
-    const secondGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
-    let request = 0
-    api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
-
-    const first = manager.insertBefore(wid('three'), wid('one'))
-    const second = manager.insertBefore(wid('two'), wid('three'))
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
-
-    firstGate.resolve(err({
-      code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' },
-    }))
-    await expect(first).resolves.toMatchObject({ ok: false })
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
-
-    secondGate.resolve(err({
-      code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' },
-    }))
-    await expect(second).resolves.toMatchObject({ ok: false })
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
-  })
+const runtimeModels = new WeakMap<WorkspaceRuntime, ClientWorkspaceModel>()
+
+function runtimeFor(
+  ctx: Context,
+  api: FakeApiClient,
+  sessions: SessionRuntime,
+): WorkspaceRuntime {
+  const model = new ClientWorkspaceModel(fakeRemote(api).workspace)
+  const runtime = new WorkspaceRuntime(ctx, api, model, sessions)
+  runtimeModels.set(runtime, model)
+  return runtime
+}
 
-  it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
-    const api = new FakeApiClient()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
-    api.onWorkspaceList = () => gate.promise
-    const manager = new WorkspaceManager(api)
-    const hydration = manager.refresh()
-    manager.handleHostEnvelope({
-      rpcId: 'removed' as never,
-      payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
-    })
-    gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
-    await hydration
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
+function baseline(
+  target: WorkspaceRuntime,
+  items: readonly WorkspaceView[] = [],
+  archivedSessionIds: readonly SessionId[] = [],
+): void {
+  modelOf(target).replaceBaseline({ items, archivedSessionIds })
+}
 
-    manager.handleHostEnvelope({
-      rpcId: 'late-change' as never,
-      payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
-    })
-    manager.handleHostEnvelope({
-      rpcId: 'duplicate-remove' as never,
-      payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
-    })
-    expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
-  })
+function modelOf(runtime: WorkspaceRuntime): ClientWorkspaceModel {
+  const model = runtimeModels.get(runtime)
+  if (model === undefined) throw new Error('WorkspaceRuntime test model missing')
+  return model
+}
 
-  it('removes from the unary delete echo while a refresh is in flight', async () => {
-    const api = new FakeApiClient()
-    api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
-    const manager = new WorkspaceManager(api)
-    await manager.refresh()
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
-    api.onWorkspaceList = () => gate.promise
-    const refresh = manager.refresh()
-
-    await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
-    expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
-    expect(manager.getSnapshot().items).toEqual([])
-    gate.resolve(ok({ items: [workspace('gone')] as never[] }))
-    await refresh
-    expect(manager.getSnapshot().items).toEqual([])
-  })
-})
+async function flush(): Promise<void> {
+  await Promise.resolve()
+  await Promise.resolve()
+}
 
 describe('WorkspaceRuntime', () => {
   it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [
-        workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
-        workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
-      ] as never[],
-    }))
-    await workspaces.refresh()
-    await Promise.resolve()
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    baseline(workspaces, [
+      workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
+      workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
+    ])
+    await flush()
     expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
 
     api.onList = () => Promise.resolve(ok({
@@ -219,11 +79,11 @@ describe('WorkspaceRuntime', () => {
   it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma')] as never[],
-    }))
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    baseline(workspaces, [
+      workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma'),
+    ])
     api.onList = () => Promise.resolve(ok({
       items: [
         // Stray blank at alpha's path but NOT accounted under alpha (a CLI
@@ -242,8 +102,8 @@ describe('WorkspaceRuntime', () => {
         { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
       ] as never[],
     }))
-    await Promise.all([workspaces.refresh(), sessions.refresh()])
-    await Promise.resolve()
+    await sessions.refresh()
+    await flush()
 
     // Hit: same workspace → the parked member blank comes back (the earlier
     // cwd-matching non-member stray is skipped), no create RPC.
@@ -278,14 +138,14 @@ describe('WorkspaceRuntime', () => {
   it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha', [sid('s-blank')])] as never[] }))
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    baseline(workspaces, [workspace('alpha', [sid('s-blank')])])
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
     }))
-    await Promise.all([workspaces.refresh(), sessions.refresh()])
-    await Promise.resolve()
+    await sessions.refresh()
+    await flush()
     const session = sessions.binding(sid('s-blank'))!.session
     api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
     await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
@@ -298,15 +158,15 @@ describe('WorkspaceRuntime', () => {
   it('returns created Workspaces and preserves Host business errors', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceCreate = () => Promise.resolve(ok({
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    api.onWorkspaceCreate = () => Promise.resolve(remoteOk({
       workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
     }))
     await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
     expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
     expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
-    api.onWorkspaceCreate = () => Promise.resolve(err({
+    api.onWorkspaceCreate = () => Promise.resolve(workspaceErr({
       code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
     }))
     const rejected = workspaces.create({ path: '/missing' })
@@ -317,8 +177,8 @@ describe('WorkspaceRuntime', () => {
   it('passes native directory selection and cancellation through without local state', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
     api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
     await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
     api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
@@ -331,7 +191,7 @@ describe('WorkspaceRuntime', () => {
   it('passes listings and creation through the browse wire, wrapping business failures', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote()))
+    const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
     const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
     api.onListDirectory = () => Promise.resolve(ok(listing))
     await expect(workspaces.listDirectory()).resolves.toEqual(listing)
@@ -352,8 +212,8 @@ describe('WorkspaceRuntime', () => {
   it('opens a filesystem path through the host without local state', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
     await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
     expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
     api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
@@ -363,15 +223,15 @@ describe('WorkspaceRuntime', () => {
   it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
-    await workspaces.refresh()
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    baseline(workspaces, [workspace('alpha')])
+    await flush()
     await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
     expect(workspaces.list.getSnapshot().items).toEqual([])
 
-    api.onWorkspaceDelete = () => Promise.resolve(err({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
+    api.onWorkspaceDelete = () => Promise.resolve(workspaceErr({
+      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
     }))
     await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
   })
@@ -379,12 +239,10 @@ describe('WorkspaceRuntime', () => {
   it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const workspaces = new WorkspaceRuntime(ctx, api, new SessionRuntime(ctx, api, fakeRemote()))
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('one'), workspace('two')] as never[],
-    }))
-    await workspaces.refresh()
-    api.onWorkspaceInsertBefore = () => Promise.resolve(ok({
+    const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
+    baseline(workspaces, [workspace('one'), workspace('two')])
+    await flush()
+    api.onWorkspaceInsertBefore = () => Promise.resolve(remoteOk({
       workspaceIds: [wid('two'), wid('one')],
     }))
     await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
@@ -393,8 +251,8 @@ describe('WorkspaceRuntime', () => {
     }])
     expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
 
-    api.onWorkspaceInsertBefore = () => Promise.resolve(err({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
+    api.onWorkspaceInsertBefore = () => Promise.resolve(workspaceErr({
+      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
     }))
     await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
   })
@@ -402,20 +260,18 @@ describe('WorkspaceRuntime', () => {
   it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
-    api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [
-        workspace('current-home', [sid('current')]),
-        workspace('recent-home', [sid('recent')]),
-      ] as never[],
-    }))
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
+    baseline(workspaces, [
+      workspace('current-home', [sid('current')]),
+      workspace('recent-home', [sid('recent')]),
+    ])
     api.onList = () => Promise.resolve(ok({ items: [
       { sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
       { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
     ] as never[] }))
-    await Promise.all([workspaces.refresh(), sessions.refresh()])
-    await Promise.resolve()
+    await sessions.refresh()
+    await flush()
     sessions.open(sid('current'))
     const unresolved = new Promise<SessionId>(() => {})
     const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
@@ -435,18 +291,18 @@ describe('WorkspaceRuntime', () => {
 
     const emptyCtx = new Context()
     const emptyApi = new FakeApiClient()
-    const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote())
-    const emptyWorkspaces = new WorkspaceRuntime(emptyCtx, emptyApi, emptySessions)
+    const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote(emptyApi))
+    const emptyWorkspaces = runtimeFor(emptyCtx, emptyApi, emptySessions)
     const clear = vi.spyOn(emptySessions, 'clear')
     emptyWorkspaces.startSession()
     expect(clear).toHaveBeenCalledOnce()
   })
 
-  it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
+  it('archives a session, projects unary and stream state, and clears only the current one', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
     api.onList = () => Promise.resolve(ok({
       items: [
         { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
@@ -463,60 +319,43 @@ describe('WorkspaceRuntime', () => {
     expect(sessions.list.getSnapshot().current).toBe('s-open')
 
     // Archiving the current session clears it into the New Session view state.
-    api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
+    api.onWorkspaceArchiveSession = () => Promise.resolve(remoteOk({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
     await workspaces.archiveSession(sid('s-open'))
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
     expect(sessions.list.getSnapshot().current).toBeUndefined()
 
     // A Host failure leaves the set and the selection untouched.
-    api.onWorkspaceArchiveSession = () => Promise.resolve(err({
+    api.onWorkspaceArchiveSession = () => Promise.resolve(workspaceErr({
       code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
     }))
     await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
 
-    // The changed frame and the list baseline both re-install the full set.
-    workspaces.handleHostEnvelope({
-      rpcId: 'frame' as never,
-      payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
-    } as never)
-    // Frame installs ride the notifier's microtask batch before projecting.
-    await new Promise(resolve => setTimeout(resolve, 0))
+    modelOf(workspaces).replaceArchived([sid('s-idle')])
+    await flush()
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
-    api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
-    await workspaces.refresh()
+    baseline(workspaces, [], [sid('s-open')])
+    await flush()
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
   })
 
-  it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
+  it('clears a current archived by a stream increment and accepts the next baseline as authoritative', async () => {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
     }) as never)
     await sessions.refresh()
     sessions.open(sid('s-open'))
 
-    // A stale baseline is in flight (older, empty set) when another tab's
-    // archive frame lands: the frame clears the current selection and its
-    // set survives the baseline's later resolution.
-    const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
-    api.onWorkspaceList = () => gate.promise
-    const hydration = workspaces.refresh()
-    workspaces.handleHostEnvelope({
-      rpcId: 'frame' as never,
-      payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
-    } as never)
-    await new Promise(resolve => setTimeout(resolve, 0))
+    modelOf(workspaces).replaceArchived([sid('s-open')])
+    await flush()
     expect(sessions.list.getSnapshot().current).toBeUndefined()
-    gate.resolve(ok({ items: [], archivedSessionIds: [] }))
-    await hydration
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
-    // The next (fresh) baseline is authoritative again.
-    api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
-    await workspaces.refresh()
+    baseline(workspaces)
+    await flush()
     expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
   })
 })
@@ -525,8 +364,8 @@ describe('startInitialSelection', () => {
   function bench() {
     const ctx = new Context()
     const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
-    const workspaces = new WorkspaceRuntime(ctx, api, sessions)
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
+    const workspaces = runtimeFor(ctx, api, sessions)
     return { api, sessions, workspaces }
   }
 
@@ -536,11 +375,8 @@ describe('startInitialSelection', () => {
     // Nothing happens before both baselines land.
     expect(b.api.callsOf('session.create')).toHaveLength(0)
 
-    b.api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
-    }))
     b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
-    await b.workspaces.refresh()
+    baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
     await b.sessions.refresh()
     // Store notifications and the connect round trip are microtask-batched.
     await new Promise(resolve => setTimeout(resolve, 0))
@@ -556,16 +392,15 @@ describe('startInitialSelection', () => {
     }))
     await withCurrent.sessions.refresh()
     withCurrent.sessions.open(sid('s1'))
-    withCurrent.api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('w1', [sid('s1')])] as never[] }))
     const stopCurrent = withCurrent.workspaces.startInitialSelection()
-    await withCurrent.workspaces.refresh()
+    baseline(withCurrent.workspaces, [workspace('w1', [sid('s1')])])
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
     stopCurrent()
 
     const noRecent = bench()
     const stopEmpty = noRecent.workspaces.startInitialSelection()
-    await noRecent.workspaces.refresh()
+    baseline(noRecent.workspaces)
     await noRecent.sessions.refresh()
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
@@ -575,20 +410,17 @@ describe('startInitialSelection', () => {
 
   it('a failed connect returns to waiting and retries on the next list change', async () => {
     const b = bench()
-    b.api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [workspace('recent', [], '2026-01-02T00:00:00.000Z')] as never[],
-    }))
     b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
     const stop = b.workspaces.startInitialSelection()
-    await b.workspaces.refresh()
+    baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
     await b.sessions.refresh()
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(b.api.callsOf('session.create')).toHaveLength(1)
     expect(b.sessions.list.getSnapshot().current).toBeUndefined()
 
-    // Recovery: the next workspace-list change re-runs the reconcile.
+    // Recovery: the next Workspace stream change re-runs the reconcile.
     b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
-    await b.workspaces.refresh()
+    modelOf(b.workspaces).upsertView(workspace('recent', [], '2026-01-03T00:00:00.000Z'))
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(b.api.callsOf('session.create')).toHaveLength(2)
     expect(b.sessions.list.getSnapshot().current).toBe('s-retry')

+ 1 - 2
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -106,12 +106,11 @@ const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
 async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) {
   const ctx = new Context()
   const api = new FakeApiClient()
-  api.onWorkspaceList = () => Promise.resolve(ok({ items: [] }))
   const sessionId = 'scenario-s1' as Parameters<SessionRuntime['open']>[0]
   api.onList = () => Promise.resolve(ok({
     items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
   }) as never)
-  const sessions = new SessionRuntime(ctx, api, fakeRemote()) // provides 'sessions' itself
+  const sessions = new SessionRuntime(ctx, api, fakeRemote(api)) // provides 'sessions' itself
   await sessions.refresh()
   await Promise.resolve() // manager notifier flush
   await ctx.plugin(InputTriggerService).await()

+ 0 - 100
packages/host/apiproxy/src/api/workspace.schema.ts

@@ -1,100 +0,0 @@
-/**
- * workspace domain zod schemas (names derived from map keys). The
- * WorkspaceId brand cast lives in sessions.schema (see the note there) and
- * is re-exported here as the domain-local name.
- */
-
-import { z } from 'zod'
-import type { RequestPayload, ResponseValue } from './rpc-map.ts'
-import type { Wire } from './rpc.schema.ts'
-import type { WorkspaceView } from './workspace.ts'
-import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
-
-export { workspaceIdSchema } from './sessions.schema.ts'
-
-/** WorkspaceView row of every workspace.* response. */
-export const workspaceViewSchema = z.object({
-  workspaceId: workspaceIdSchema,
-  path: z.string(),
-  title: z.string(),
-  sessionIds: z.array(sessionIdSchema),
-  createdAt: z.string(),
-  updatedAt: z.string(),
-}) satisfies z.ZodType<Wire<WorkspaceView>>
-
-/** workspace.list request payload (empty object literal). */
-export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
-
-/** workspace.list response value. */
-export const workspaceListValueSchema = z.object({
-  items: z.array(workspaceViewSchema),
-  archivedSessionIds: z.array(sessionIdSchema),
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
-
-/** workspace.create request payload: the existing directory to adopt. */
-export const workspaceCreateRequestSchema = z.object({
-  path: z.string(),
-}) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
-
-/** workspace.create response value. */
-export const workspaceCreateValueSchema = z.object({
-  workspace: workspaceViewSchema,
-  created: z.boolean(),
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
-
-/** workspace.rename request payload: the new title must be non-blank. */
-export const workspaceRenameRequestSchema = z.object({
-  workspaceId: workspaceIdSchema,
-  title: z.string(),
-}).refine(
-  payload => payload.title.trim() !== '',
-  { message: 'workspace.rename requires a non-blank title' },
-) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
-
-/** workspace.rename response value. */
-export const workspaceRenameValueSchema = z.object({
-  workspace: workspaceViewSchema,
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
-
-/** workspace.delete request payload. */
-export const workspaceDeleteRequestSchema = z.object({
-  workspaceId: workspaceIdSchema,
-}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
-
-/** workspace.delete response value. */
-export const workspaceDeleteValueSchema = z.object({
-  deleted: z.literal(true),
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
-
-/** workspace.insertBefore request payload (anchor omitted = append to end). */
-export const workspaceInsertBeforeRequestSchema = z.object({
-  workspaceId: workspaceIdSchema,
-  beforeWorkspaceId: workspaceIdSchema.optional(),
-}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertBefore'>>>
-
-/** workspace.insertBefore response value: the complete durable display order. */
-export const workspaceInsertBeforeValueSchema = z.object({
-  workspaceIds: z.array(workspaceIdSchema),
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertBefore'>>>
-
-/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
-export const workspaceInsertSessionBeforeRequestSchema = z.object({
-  workspaceId: workspaceIdSchema,
-  sessionId: sessionIdSchema,
-  beforeSessionId: sessionIdSchema.optional(),
-}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
-
-/** workspace.insertSessionBefore response value. */
-export const workspaceInsertSessionBeforeValueSchema = z.object({
-  workspace: workspaceViewSchema,
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>
-
-/** workspace.archiveSession request payload. */
-export const workspaceArchiveSessionRequestSchema = z.object({
-  sessionId: sessionIdSchema,
-}) satisfies z.ZodType<Wire<RequestPayload<'workspace.archiveSession'>>>
-
-/** workspace.archiveSession response value: the full updated archive set. */
-export const workspaceArchiveSessionValueSchema = z.object({
-  archivedSessionIds: z.array(sessionIdSchema),
-}) satisfies z.ZodType<Wire<ResponseValue<'workspace.archiveSession'>>>

+ 0 - 109
packages/host/apiproxy/src/api/workspace.ts

@@ -1,109 +0,0 @@
-/**
- * workspace domain contract. Wire projection of the host-side workspace
- * entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
- * a display title, and the ordered session account. Method signatures are the
- * source of truth, same as the sessions domain.
- */
-
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { RpcRequest, RpcResponse } from './rpc.ts'
-
-/**
- * Wire-side workspace id brand. Deliberately re-declared here rather than
- * imported from dsh-workspace: api/ must stay browser-importable with zero
- * host-package dependencies, and the brand string matches, so both sides
- * agree structurally.
- */
-export type WorkspaceId = Branded<'WorkspaceId'>
-
-/** One workspace row: the record projection every workspace.* value carries. */
-export interface WorkspaceView {
-  workspaceId: WorkspaceId
-  /** Canonical directory path (host-side realpath canon). */
-  path: string
-  /** Display title (defaults to the path basename at create). */
-  title: string
-  /**
-   * Sessions accounted under this workspace, in manually owned order
-   * (attach prepends, insertSessionBefore reorders; activity never does).
-   */
-  sessionIds: SessionId[]
-  /** ISO-8601 creation instant. */
-  createdAt: string
-  /** ISO-8601 last-mutation instant. */
-  updatedAt: string
-}
-
-/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
-export interface WorkspaceApi {
-  /**
-   * Lists all workspaces in the registry's durable display order, plus the
-   * registry-global archive set (the reconnect baseline of
-   * `host/archived-sessions-changed`). Archived sessions stay in their
-   * workspace's `sessionIds` account; grouping surfaces hide them.
-   */
-  list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
-
-  /**
-   * Creates (or idempotently resolves) a workspace over an EXISTING directory
-   * (no mkdir — a missing or non-directory path fails with
-   * `workspace-invalid-path`). A path resolving to a directory already owned
-   * by a workspace returns that workspace (`created: false`). Adoption allows
-   * distinct canonical paths whose basenames produce the same display title;
-   * the registry's basename title default names the new workspace.
-   */
-  create(request: RpcRequest<{ path: string }>):
-  Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
-
-  /**
-   * Renames a workspace. `title` is trimmed and must be non-empty
-   * (schema-enforced). An unknown id fails with `workspace-not-found`; a
-   * title equal to another workspace's fails with `workspace-name-conflict`.
-   * Renaming to the current title is a no-op success (no durable write).
-   */
-  rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
-  Promise<RpcResponse<{ workspace: WorkspaceView }>>
-
-  /**
-   * Removes one Workspace registration. The directory, every user file, and
-   * every session log remain untouched; those Sessions consequently become
-   * ungrouped. An unknown id fails with `workspace-not-found`.
-   */
-  delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
-  Promise<RpcResponse<{ deleted: true }>>
-
-  /**
-   * Moves one Workspace within the registry display order,
-   * DOM-insertBefore-like. An omitted anchor appends to the end.
-   */
-  insertBefore(request: RpcRequest<{
-    workspaceId: WorkspaceId
-    beforeWorkspaceId?: WorkspaceId
-  }>): Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>>
-
-  /**
-   * Moves an accounted session within its workspace's manual order,
-   * DOM-insertBefore-like: with `beforeSessionId` the session is inserted
-   * before that anchor; omitted appends to the end. An unknown workspace
-   * fails with `workspace-not-found`; a session or anchor not accounted by
-   * the workspace fails with `workspace-move-invalid`. A move to the current
-   * position is a no-op success.
-   */
-  insertSessionBefore(request: RpcRequest<{
-    workspaceId: WorkspaceId
-    sessionId: SessionId
-    beforeSessionId?: SessionId
-  }>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
-
-  /**
-   * Adds one session to the registry-global archive set: the session
-   * disappears from every grouping surface but keeps its session log and its
-   * workspace accounting slot (a future unarchive restores its position).
-   * Idempotent for an already archived id. A session neither live nor in
-   * session persistence fails with `session-not-found`. Returns the full
-   * updated set (same snapshot the changed frame carries).
-   */
-  archiveSession(request: RpcRequest<{ sessionId: SessionId }>):
-  Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>>
-}

+ 0 - 571
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -1,571 +0,0 @@
-import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
-import { homedir, tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session } from '@deepseek-ai/dsh-session'
-import Storage from '@deepseek-ai/dsh-storage'
-import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
-import UserQuestionService from '@deepseek-ai/dsh-user-questions'
-import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
-import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
-import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
-import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
-import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
-
-let nextRpc = 1
-
-function request<P>(payload: P): RpcRequest<P> {
-  return { rpcId: RpcId(`workspace-${String(nextRpc++)}`), payload }
-}
-
-function expectOk<T>(response: RpcResponse<T>): T {
-  expect(response.result.ok).toBe(true)
-  if (!response.result.ok) throw new Error('unreachable')
-  return response.result.value
-}
-
-async function nextHostFrame(
-  stream: AsyncIterator<RpcRequest<HostFrame>>,
-): Promise<RpcRequest<HostFrame>> {
-  const next = await stream.next()
-  if (next.done === true) throw new Error('Host stream ended before the expected increment')
-  return next.value
-}
-
-function stubAgent(session: Session): Agent {
-  return {
-    id: session.id,
-    options: {},
-    session,
-    inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
-    status: 'idle',
-    ctx: new Context(),
-    send: () => {},
-    followup: () => {},
-    steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
-    inject: () => {},
-    cancel() {},
-    runMaintenance: job => job(new AbortController().signal),
-    whenIdle: () => Promise.resolve(),
-  }
-}
-
-/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
-async function harness(
-  root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
-  picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
-  extras: {
-    openPath?: (path: string, signal: AbortSignal) => Promise<void>
-    canOpenPath?: () => boolean
-  } = {},
-) {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(AgentRegistry)
-  await ctx.plugin(UserQuestionService)
-  await ctx.plugin(Storage)
-  ctx.storage.backend.register('memory', new MemoryStorageBackend())
-  const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-  ctx.storage.mount('domain', storageDomain)
-  ctx.provide('storageDomain', storageDomain)
-  ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
-  await ctx.plugin(WorkspaceRegistry)
-
-  const factory: AgentFactory = {
-    async createAgent(_ownerCtx, options) {
-      const session = ctx.sessions.create(
-        options.sessionId,
-        options.meta === undefined ? {} : { meta: options.meta },
-      )
-      const agent = stubAgent(session)
-      const unregister = ctx.agents.register(agent)
-      return {
-        agent,
-        dispose: () => {
-          unregister()
-          return Promise.resolve()
-        },
-      }
-    },
-    async resume() {
-      throw new Error('test harness has no persisted sessions')
-    },
-  }
-  ctx.agents.setFactory(factory)
-  // Structural picker fake: the gateway only reads capability(); a stable
-  // object per harness mirrors the seam's stability contract.
-  ctx.provide('directoryPicker', { capability: () => picker } as never)
-  const api = createApiProxy(ctx, {
-    defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
-    cwd: root,
-    ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
-    ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath },
-  })
-  return { api, ctx, storageDomain, root }
-}
-
-/** Stage one directory under the harness root for path adoption. */
-function stageDir(root: string, name: string): string {
-  const path = join(root, name)
-  mkdirSync(path)
-  return path
-}
-
-describe('host.pickDirectory', () => {
-  it('returns a selected path or explicit cancellation from the native capability', async () => {
-    const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
-    expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
-      .toEqual({ ok: true, value: { path: '/tmp/project' } })
-
-    const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
-    expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
-      .toEqual({ ok: true, value: { path: null } })
-  })
-
-  it('propagates abort into the native capability as a cancelled RPC error', async () => {
-    const { api } = await harness(undefined, {
-      kind: 'native',
-      pick: signal => new Promise((_resolve, reject) => {
-        signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
-      }),
-    })
-    const abort = new AbortController()
-    const pending = api.host.pickDirectory(request({}), abort.signal)
-    abort.abort()
-    expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
-  })
-
-  it('folds a non-abort native-chooser failure into an internal error', async () => {
-    const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
-    const response = await api.host.pickDirectory(request({}), new AbortController().signal)
-    expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
-  })
-
-  it('refuses the native RPC under a browse composition', async () => {
-    const { api } = await harness(undefined, BROWSE_STUB)
-    const response = await api.host.pickDirectory(request({}), new AbortController().signal)
-    expect(response.result).toMatchObject({
-      ok: false,
-      error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
-    })
-  })
-})
-
-/** Canned browse capability: one listing, one created path, typed failures on demand. */
-const BROWSE_STUB: DirectoryPickerCapability = {
-  kind: 'browse',
-  list: async (path) => {
-    if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
-    const target = path ?? '/home/user'
-    return {
-      path: target,
-      home: '/home/user',
-      crumbs: [{ name: '/', path: '/', hidden: false }],
-      entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
-      truncated: false,
-    }
-  },
-  createDirectory: async (path, name) => {
-    if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
-    if (name === 'unwritable') throw new Error('disk detached')
-    return `${path}/${name}`
-  },
-}
-
-describe('host.listDirectory / host.createDirectory', () => {
-  it('serves listings and creation through the browse capability, defaulting to home', async () => {
-    const { api } = await harness(undefined, BROWSE_STUB)
-    const home = await api.host.listDirectory(request({}), new AbortController().signal)
-    expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
-    const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
-    expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
-    const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
-    expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
-  })
-
-  it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
-    const { api } = await harness(undefined, BROWSE_STUB)
-    expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({
-      ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
-    })
-    expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
-      ok: false, error: { code: 'directory-exists' },
-    })
-    expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
-      ok: false, error: { code: 'internal' },
-    })
-  })
-
-  it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
-    const { api } = await harness(undefined, {
-      kind: 'browse',
-      list: (_path, signal) => new Promise((_resolve, reject) => {
-        signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
-      }),
-      createDirectory: async () => '/never',
-    })
-    const abort = new AbortController()
-    const pending = api.host.listDirectory(request({}), abort.signal)
-    abort.abort()
-    expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
-  })
-
-  it('refuses the browse RPCs under a native composition', async () => {
-    const { api } = await harness()
-    expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
-      ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
-    })
-    expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
-      ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
-    })
-  })
-})
-
-describe('host.openPath', () => {
-  it('describes whether this deployment can reach a user-visible native desktop', async () => {
-    const visible = await harness(undefined, undefined, { canOpenPath: () => true })
-    const headless = await harness(undefined, undefined, { canOpenPath: () => false })
-    expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
-    expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
-    expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
-  })
-
-  it('opens through the injected native boundary', async () => {
-    const opened: string[] = []
-    const { api } = await harness(undefined, undefined, {
-      openPath: async (path) => { opened.push(path) },
-    })
-    expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
-      .toEqual({ ok: true, value: { opened: true } })
-    expect(opened).toEqual(['/tmp/a.txt'])
-  })
-
-  it('propagates abort into the native boundary as a cancelled RPC error', async () => {
-    const { api } = await harness(undefined, undefined, {
-      openPath: (_path, signal) => new Promise((_resolve, reject) => {
-        signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
-      }),
-    })
-    const abort = new AbortController()
-    const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
-    abort.abort()
-    expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
-  })
-})
-
-describe('workspace.create', () => {
-  it('serializes concurrent creates of one path into a single registration', async () => {
-    const { api, root } = await harness()
-    const target = stageDir(root, 'alpha')
-    const responses = await Promise.all([
-      api.workspace.create(request({ path: target })),
-      api.workspace.create(request({ path: target })),
-    ])
-    const values = responses.map(response => expectOk(response))
-    const created = values.find(value => value.created)
-    const resolved = values.find(value => !value.created)
-
-    expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
-    expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
-    expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
-  })
-
-  it('adopts only existing directories', async () => {
-    const { api, root } = await harness()
-    const existing = stageDir(root, 'existing')
-    const first = expectOk(await api.workspace.create(request({ path: existing })))
-    const repeated = expectOk(await api.workspace.create(request({ path: existing })))
-    expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
-    expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
-
-    expectOk(await api.workspace.rename(request({
-      workspaceId: first.workspace.workspaceId,
-      title: 'renamed-existing',
-    })))
-    const reopened = expectOk(await api.workspace.create(request({ path: existing })))
-    expect(reopened.workspace.title).toBe('renamed-existing')
-
-    const missing = join(root, 'missing')
-    const missingResult = await api.workspace.create(request({ path: missing }))
-    expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
-    expect(existsSync(missing)).toBe(false)
-  })
-
-  it('adopts different paths that derive the same Workspace title', async () => {
-    const { api, root } = await harness()
-    const first = join(root, 'one', 'project')
-    const second = join(root, 'two', 'project')
-    mkdirSync(first, { recursive: true })
-    mkdirSync(second, { recursive: true })
-    const firstResult = expectOk(await api.workspace.create(request({ path: first })))
-    const secondResult = expectOk(await api.workspace.create(request({ path: second })))
-    expect(firstResult).toMatchObject({
-      created: true,
-      workspace: { path: first, title: 'project' },
-    })
-    expect(secondResult).toMatchObject({
-      created: true,
-      workspace: { path: second, title: 'project' },
-    })
-    expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId)
-    expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path))
-      .toEqual([second, first])
-  })
-})
-
-describe('workspace.insertBefore', () => {
-  it('commits the complete order, streams one order frame, and maps unknown ids', async () => {
-    const { api, ctx, root } = await harness()
-    const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace
-    const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace
-    const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace
-
-    const abort = new AbortController()
-    const listWorkspaces = vi.spyOn(ctx.workspaceRegistry, 'list')
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    expect(listWorkspaces).toHaveBeenCalledTimes(1)
-    const changed = nextHostFrame(stream)
-    const reordered = expectOk(await api.workspace.insertBefore(request({
-      workspaceId: first.workspaceId,
-      beforeWorkspaceId: second.workspaceId,
-    })))
-    expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId])
-    expect(await changed).toMatchObject({
-      payload: {
-        type: 'host/workspace-order-changed',
-        workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId],
-      },
-    })
-    expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId))
-      .toEqual(reordered.workspaceIds)
-
-    const missingSource = await api.workspace.insertBefore(request({
-      workspaceId: 'missing' as WorkspaceId,
-    }))
-    expect(missingSource.result).toMatchObject({
-      ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } },
-    })
-    const missingAnchor = await api.workspace.insertBefore(request({
-      workspaceId: first.workspaceId,
-      beforeWorkspaceId: 'missing-anchor' as WorkspaceId,
-    }))
-    expect(missingAnchor.result).toMatchObject({
-      ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } },
-    })
-    abort.abort()
-  })
-})
-
-describe('session creation and Workspace membership', () => {
-  it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
-    const { api, ctx, root } = await harness()
-    const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
-    const sessionId = SessionId('session-workspace-preallocated')
-
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
-    expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
-    expect(ctx.agents.list().filter(agent => agent.id === sessionId)).toHaveLength(1)
-
-    const ungrouped = SessionId('session-cwd-only')
-    expectOk(await api.sessions.create(request({ cwd: workspace.path, sessionId: ungrouped })))
-    expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
-    expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(ungrouped)
-
-    const conflict = await api.sessions.create(request({ cwd: join(workspace.path, 'other'), sessionId }))
-    expect(conflict.result).toMatchObject({
-      ok: false,
-      error: { code: 'session-conflict', details: { sessionId, existingCwd: workspace.path } },
-    })
-    const missing = await api.sessions.create(request({
-      workspaceId: 'missing-workspace' as WorkspaceId,
-      sessionId: SessionId('session-missing-workspace'),
-    }))
-    expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
-  })
-
-  it('retains a published session when attachment fails and repairs it on retry', async () => {
-    const { api, ctx, root } = await harness()
-    const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
-    const workspace = ctx.workspaceRegistry.list()[0]
-    if (workspace === undefined) throw new Error('workspace missing from registry')
-    vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
-    const sessionId = SessionId('session-attach-retry')
-
-    const failed = await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId }))
-    expect(failed.result).toMatchObject({
-      ok: false,
-      error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: created.workspaceId } },
-    })
-    expect(ctx.agents.get(sessionId)).toBeDefined()
-
-    expectOk(await api.sessions.create(request({ workspaceId: created.workspaceId, sessionId })))
-    expect(expectOk(await api.workspace.list(request({}))).items[0]?.sessionIds).toEqual([sessionId])
-  })
-})
-
-describe('Host Workspace increments', () => {
-  it('projects subagent origin in attached summaries and creation increments', async () => {
-    const { api, ctx } = await harness()
-    const abort = new AbortController()
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    const pending = nextHostFrame(stream)
-    const childId = SessionId('session-subagent-child')
-
-    ctx.sessions.create(childId, {
-      meta: {
-        cwd: '/tmp',
-        parentSession: SessionId('session-parent'),
-        origin: 'subagent',
-      },
-    })
-
-    expect(await pending).toMatchObject({
-      payload: {
-        type: 'host/session-added',
-        sessionId: childId,
-        parentSessionId: 'session-parent',
-        origin: 'subagent',
-      },
-    })
-    expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
-      expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
-    )
-    abort.abort()
-  })
-
-  it('streams committed Workspace and Session increments after empty baselines', async () => {
-    const { api, root } = await harness()
-    expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
-    expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
-
-    const abort = new AbortController()
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    const workspaceIncrement = nextHostFrame(stream)
-    const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
-    expect(await workspaceIncrement).toMatchObject({
-      payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
-    })
-
-    const sessionId = SessionId('session-streamed-workspace')
-    const pending = nextHostFrame(stream)
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
-    const increments: HostFrame[] = []
-    increments.push((await pending).payload)
-    while (increments.length < 2) {
-      const next = await stream.next()
-      if (next.done === true) throw new Error('Host stream ended before both increments')
-      increments.push(next.value.payload)
-    }
-    expect(increments.find(increment => increment.type === 'host/session-added')).toMatchObject({
-      // A just-created session has no events: the frame constantly carries blank:true.
-      type: 'host/session-added', sessionId, blank: true, cwd: workspace.path,
-    })
-    const workspaceChanged = increments.find(
-      (increment): increment is Extract<HostFrame, { type: 'host/workspace-changed' }> =>
-        increment.type === 'host/workspace-changed',
-    )
-    expect(workspaceChanged?.workspace.sessionIds).toEqual([sessionId])
-    abort.abort()
-  })
-
-  it('does not publish a Workspace whose registry-order commit fails', async () => {
-    const { api, storageDomain, root } = await harness()
-    const domain = storageDomain.get('workspace')
-    if (domain === undefined) throw new Error('workspace domain is not open')
-    vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
-    const abort = new AbortController()
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    const next = stream.next()
-
-    const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
-    expect(failed.result.ok).toBe(false)
-    expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
-    abort.abort()
-    expect(await next).toMatchObject({ done: true })
-  })
-
-  it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
-    const { api, ctx, root } = await harness()
-    const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
-    const sessionId = SessionId('session-kept-after-workspace-delete')
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
-
-    const abort = new AbortController()
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    const removed = nextHostFrame(stream)
-    expectOk(await api.workspace.delete(request({ workspaceId: workspace.workspaceId })))
-    expect(await removed).toMatchObject({
-      payload: { type: 'host/workspace-removed', workspaceId: workspace.workspaceId },
-    })
-    expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
-    expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
-    expect(ctx.agents.get(sessionId)).toBeDefined()
-    expect(existsSync(workspace.path)).toBe(true)
-
-    const missing = await api.workspace.delete(request({ workspaceId: workspace.workspaceId }))
-    expect(missing.result).toMatchObject({
-      ok: false,
-      error: { code: 'workspace-not-found', details: { workspaceId: workspace.workspaceId } },
-    })
-
-    const reregistered = expectOk(await api.workspace.create(request({ path: workspace.path }))).workspace
-    expect(reregistered.workspaceId).not.toBe(workspace.workspaceId)
-    expect(reregistered.path).toBe(workspace.path)
-    expect(reregistered.sessionIds).toEqual([])
-    expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
-    abort.abort()
-  })
-
-  it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
-    const { api, root } = await harness()
-    const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
-    const sessionId = SessionId('session-to-archive')
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
-    expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
-
-    const abort = new AbortController()
-    const stream: AsyncIterator<RpcRequest<HostFrame>> =
-      api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
-    const changed = nextHostFrame(stream)
-    expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
-      .toEqual([sessionId])
-    expect(await changed).toMatchObject({
-      payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] },
-    })
-
-    // Accounting and the session itself are untouched; list re-baselines the set.
-    const listed = expectOk(await api.workspace.list(request({})))
-    expect(listed.archivedSessionIds).toEqual([sessionId])
-    expect(listed.items[0]?.sessionIds).toEqual([sessionId])
-    expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId)
-
-    // The idempotent repeat emits no second frame: the next observed frame is
-    // the workspace-changed of a later attach, not another archive snapshot.
-    const after = nextHostFrame(stream)
-    expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds)
-      .toEqual([sessionId])
-    const otherSession = SessionId('session-after-archive')
-    expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession })))
-    expect((await after).payload.type).not.toBe('host/archived-sessions-changed')
-
-    const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') }))
-    expect(missing.result).toMatchObject({
-      ok: false,
-      error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } },
-    })
-    abort.abort()
-  })
-})