refactor(workspace): move Client ownership into Workspace Controller

This commit is contained in:
imccyu
2026-08-23 16:28:16 +08:00
parent 956730a5fb
commit 0ea9a456c0
8 changed files with 408 additions and 27 deletions
@@ -47,7 +47,8 @@
"@deepseek-ai/dsh-api-gateway/client"
],
"inject": [
"@deepseek-ai/dsh-api-gateway"
"@deepseek-ai/dsh-api-gateway",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
}
@@ -74,6 +75,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
@@ -83,6 +85,8 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
@@ -1,5 +1,6 @@
/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */
import type { Context } from '@deepseek-ai/cordis'
import {
RemoteSnapshotStream,
RemoteStreamCarrierError,
@@ -7,14 +8,20 @@ import {
} from '@deepseek-ai/dsh-api-gateway/client'
import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts'
import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts'
import { ClientWorkspaceModel } from './model.ts'
import { WorkspaceController } from './service.ts'
export { ClientWorkspaceModel } from './model.ts'
export type {
WorkspaceFollowSink, WorkspaceListPhase, WorkspaceListSnapshot, WorkspaceRemote,
WorkspaceFollowSink, WorkspaceListPhase, WorkspaceRemote, WorkspaceSnapshot,
} from './model.ts'
export { abbreviateHomePath, resolveWorkspacePath } from './path.ts'
export { WorkspaceController, WorkspaceCreateError } from './service.ts'
export type { IWorkspaces, WorkspaceSource } from './service.ts'
export type { WorkspaceId, WorkspaceView } from '../types.ts'
type WorkspaceStreamRemote = Pick<ClientRemote, '$stream'> & {
readonly workspace: Pick<WorkspaceRemote, 'follow'>
readonly workspace: WorkspaceRemote
}
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
@@ -25,8 +32,35 @@ export type WorkspaceStateStream = RemoteSnapshotStream<
WorkspaceFollowIncrement
>
/** Workspace Controller's Client row exports library values and installs no Cordis service. */
export function apply(): void {}
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free Client Workspace state and commands. */
workspaces: import('./service.ts').IWorkspaces
}
}
/** Required Client Remote services. */
export const inject = ['remote', 'remote.workspace']
/**
* Install Client Workspace state, commands, and reconnecting follow control.
* @param ctx - Client root Context.
*/
export function apply(ctx: Context): void {
const remote = ctx.remote as WorkspaceStreamRemote
const model = new ClientWorkspaceModel(remote.workspace)
new WorkspaceController(ctx, model)
const control = createWorkspaceStateStream(remote, {
accept: model,
carrierFailed: () => { model.handleCarrierFailure() },
failed: (error) => { model.handleStreamFailure(error) },
})
control.start()
ctx.effect(
() => async () => { await control.dispose() },
'workspace-controller.client.control',
)
}
/** Domain sinks used by the Workspace state stream. */
export interface WorkspaceStateStreamOptions {
@@ -1,5 +1,6 @@
/** Client-side Workspace state model shared by Remote transport and UI projection. */
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type {
@@ -23,7 +24,7 @@ export type WorkspaceRemote = TypertClientRemote['workspace']
export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable Client Workspace state. */
export interface WorkspaceListSnapshot {
export interface WorkspaceSnapshot {
readonly items: readonly WorkspaceView[]
/** Complete registry-global archive set in Host order. */
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
@@ -52,7 +53,7 @@ export interface WorkspaceFollowSink {
export class ClientWorkspaceModel implements WorkspaceFollowSink {
private items: readonly WorkspaceView[] = []
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
private state: WorkspaceListSnapshot['state'] = 'loading'
private state: WorkspaceSnapshot['state'] = 'loading'
private phase: WorkspaceListPhase = 'pending'
private error: RemoteFailure | null = null
/** Latest local reorder request; only its unary echo may install order. */
@@ -64,7 +65,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
/** 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 snapshotCache: WorkspaceSnapshot
private snapshotDirty = false
private notificationPending = false
private notificationScheduled = false
@@ -251,12 +252,12 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
* Read the cached state, rebuilding it first when necessary.
* @returns the current stable Workspace list snapshot.
*/
getSnapshot(): WorkspaceListSnapshot {
getSnapshot(): WorkspaceSnapshot {
this.refreshSnapshot()
return this.snapshotCache
}
private buildSnapshot(): WorkspaceListSnapshot {
private buildSnapshot(): WorkspaceSnapshot {
return {
items: this.items,
archivedSessionIds: this.archivedSessionIds,
@@ -346,7 +347,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
if (!this.notificationPending || this.listeners.size === 0) return
this.notificationPending = false
this.refreshSnapshot()
for (const listener of this.listeners) listener()
notifySubscribers(this.listeners, '[workspace-controller]')
}
private refreshSnapshot(): void {
@@ -0,0 +1,36 @@
/**
* Resolve a workspace-relative path into the Host-facing spelling used by openPath.
* @param cwd - Session Workspace root, when known.
* @param path - absolute or Workspace-relative path.
* @returns an absolute path when a Workspace root is available, otherwise the original path.
*/
export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
if (path.startsWith('/') || isWindowsStylePath(path)) return path
if (cwd === undefined || cwd === '') return path
const base = cwd.replace(/[/\\]+$/, '')
const rel = path.replace(/^[/\\]+/, '')
return `${base}/${rel}`
}
/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */
function isWindowsStylePath(value: string): boolean {
return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
}
/**
* Display-only POSIX home abbreviation. Windows drive and UNC paths stay
* verbatim, including when `home` itself is a Windows path. A missing, empty,
* or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`.
* @param path - absolute or already-short display path.
* @param home - Host account home from `host.describe`; absent skips abbreviation.
* @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`.
*/
export function abbreviateHomePath(path: string, home?: string): string {
if (home === undefined || home === '') return path
if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path
const root = home.replace(/\/+$/, '')
if (root === '' || root === '/') return path
if (path.replace(/\/+$/, '') === root) return '~'
if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}`
return path
}
@@ -0,0 +1,131 @@
/** React-free Client Workspace service and command facade. */
import { Service, type Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { WorkspaceId, WorkspaceView } from '../types.ts'
import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts'
/** Structured create failure for callers that distinguish Host business errors. */
export class WorkspaceCreateError extends Error {
override readonly name = 'WorkspaceCreateError'
/** @param rpcError - Host business or folded transport failure. */
constructor(readonly rpcError: RemoteFailure) {
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Bare observable source for the Workspace Controller snapshot. */
export interface WorkspaceSource {
/** Read the identity-stable current snapshot. */
getSnapshot(): WorkspaceSnapshot
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void
}
/** Workspace Controller's Client service face. */
export interface IWorkspaces {
/** Host-authoritative Workspace rows, order, archive set, and follow lifecycle. */
readonly list: WorkspaceSource
/**
* Register an existing path as a Workspace.
* @param input - Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { path: string }): Promise<WorkspaceView>
/**
* Rename a Workspace.
* @param workspaceId - target Workspace.
* @param title - new display title.
* @returns the renamed Workspace.
*/
rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
/**
* Delete a Workspace registration without deleting Sessions or files.
* @param workspaceId - target Workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move a Workspace within the Host registry order.
* @param workspaceId - Workspace to move.
* @param beforeWorkspaceId - anchor Workspace; omitted appends.
*/
insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
/**
* Archive a Session from Workspace grouping surfaces.
* @param sessionId - Session to archive.
*/
archiveSession(sessionId: SessionId): Promise<void>
/**
* Move a Session within one Workspace account.
* @param workspaceId - owning Workspace.
* @param sessionId - Session to move.
* @param beforeSessionId - anchor Session; omitted appends.
* @returns the changed Workspace.
*/
insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView>
}
/** Owns the bare Workspace snapshot and Workspace-only commands. */
export class WorkspaceController extends Service implements IWorkspaces {
readonly list: WorkspaceSource
/**
* @param ctx - Client root Context.
* @param model - Remote-backed Workspace state model.
*/
constructor(ctx: Context, private readonly model: ClientWorkspaceModel) {
super(ctx, 'workspaces')
this.list = model
}
async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.model.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace
}
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
const result = await this.model.rename(workspaceId, title)
if (!result.ok) throw commandError('rename', result.error)
return result.value.workspace
}
async delete(workspaceId: WorkspaceId): Promise<void> {
const result = await this.model.delete(workspaceId)
if (!result.ok) throw commandError('delete', result.error)
}
async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId)
if (!result.ok) throw commandError('reorder', result.error)
}
async archiveSession(sessionId: SessionId): Promise<void> {
const result = await this.model.archiveSession(sessionId)
if (!result.ok) throw commandError('session archive', result.error)
}
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView> {
const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
if (!result.ok) throw commandError('move', result.error)
return result.value.workspace
}
}
function commandError(operation: string, failure: RemoteFailure): Error {
return new Error(`workspace ${operation} failed: ${failure.code}: ${failure.message}`)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/path.ts'
describe('abbreviateHomePath', () => {
it('collapses a POSIX home and its descendants', () => {
expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~')
expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~')
expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project')
expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/')
})
it('keeps prefix-adjacent names and non-home paths', () => {
expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts')
expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts')
expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts')
expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already')
})
it('does not abbreviate when home is missing, empty, or the filesystem root', () => {
expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts')
expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts')
expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts')
expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts')
})
it('leaves Windows drive and UNC paths verbatim', () => {
expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project')
expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project')
expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project')
expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u')
})
})
describe('resolveWorkspacePath', () => {
it('joins a relative path under cwd and passes absolute paths through', () => {
expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts')
expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
})
})
@@ -1,14 +1,19 @@
import { Context } from '@deepseek-ai/cordis'
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 { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import * as WorkspaceClientPlugin from '../src/client/index.ts'
import {
apply,
ClientWorkspaceModel,
createWorkspaceStateStream,
WorkspaceController,
WorkspaceCreateError,
type WorkspaceFollowSink,
type WorkspaceRemote,
} from '../src/client/index.ts'
@@ -24,15 +29,12 @@ import type {
WorkspaceInsertSessionBeforeRequest,
WorkspaceOrderValue,
WorkspaceRenameRequest,
WorkspaceError,
WorkspaceId,
WorkspaceValue,
WorkspaceView,
} from '../src/types.ts'
interface Generation {
readonly frames: readonly WorkspaceFollowFrame[]
readonly error?: unknown
readonly hold?: boolean
}
const AVAILABLE_CONNECTION = {
hostDescription: {
getSnapshot: () => ({
@@ -52,6 +54,14 @@ function workspaceClient(
}
}
interface Generation {
readonly frames: readonly WorkspaceFollowFrame[]
readonly error?: unknown
readonly hold?: boolean
readonly afterAbort?: () => void
readonly afterAbortError?: unknown
}
const baseline = (id?: string): Extract<WorkspaceFollowFrame, { type: 'baseline' }> => ({
type: 'baseline',
value: {
@@ -67,6 +77,29 @@ const baseline = (id?: string): Extract<WorkspaceFollowFrame, { type: 'baseline'
},
})
const wid = (id: string): WorkspaceId => id as WorkspaceId
const sid = (id: string): SessionId => SessionId(id)
function workspace(id: string, overrides: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: wid(id),
path: `/work/${id}`,
title: id,
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function remoteOk<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
function remoteFailure(error: WorkspaceError): RemoteResult<never> {
return { ok: false, error }
}
function accepts(overrides: Partial<WorkspaceFollowSink> = {}): WorkspaceFollowSink {
const ignore = (): void => {}
return {
@@ -119,13 +152,128 @@ class ScriptedWorkspaceRemote implements WorkspaceRemote {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
generation.afterAbort?.()
if (generation.afterAbortError !== undefined) throw generation.afterAbortError
}
}
}
describe('Workspace Client snapshot adapter', () => {
it('installs no Client service and maps the baseline plus every increment', async () => {
apply()
class CommandWorkspaceRemote implements WorkspaceRemote {
readonly create = vi.fn<WorkspaceRemote['create']>(request => Promise.resolve(remoteOk({
workspace: workspace('created', { path: request.path }),
created: true,
})))
readonly rename = vi.fn<WorkspaceRemote['rename']>(request => Promise.resolve(remoteOk({
workspace: workspace(String(request.workspaceId), { title: request.title }),
})))
readonly delete = vi.fn<WorkspaceRemote['delete']>(() => Promise.resolve(remoteOk({ deleted: true })))
readonly insertBefore = vi.fn<WorkspaceRemote['insertBefore']>(request => Promise.resolve(remoteOk({
workspaceIds: [request.workspaceId],
})))
readonly insertSessionBefore = vi.fn<WorkspaceRemote['insertSessionBefore']>(request => Promise.resolve(remoteOk({
workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }),
})))
readonly archiveSession = vi.fn<WorkspaceRemote['archiveSession']>(request => Promise.resolve(remoteOk({
archivedSessionIds: [request.sessionId],
})))
async *follow(_signal?: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {}
}
async function waitFor(check: () => void): Promise<void> {
for (let attempt = 0; attempt < 40; attempt++) {
try {
check()
return
} catch {
await Promise.resolve()
}
}
check()
}
function provideClientServices(ctx: Context, remote: WorkspaceRemote): void {
const connection: ConnectionHandle = {
api: {} as ConnectionHandle['api'],
isLoopback: true,
hostDescription: {
getSnapshot: () => ({
version: 'fixture',
cwd: '/fixture',
attachedSessions: 0,
home: '/home/fixture',
canOpenPath: true,
}),
subscribe: () => () => {},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
registerGenerationSource: () => () => {},
start: () => ({ stop: () => {} }),
}
ctx.reflect.provide('connection', connection)
ctx.reflect.provide('remote', workspaceClient(remote, connection))
ctx.reflect.provide('remote.workspace', remote)
}
describe('Workspace Controller Client apply', () => {
it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => {
const ctx = new Context()
const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }])
provideClientServices(ctx, remote)
const fiber = ctx.plugin(WorkspaceClientPlugin)
await fiber
await waitFor(() => {
expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
phase: 'ready',
state: 'idle',
items: [{ workspaceId: 'mounted' }],
})
})
await fiber.dispose()
expect(remote.signals[0]?.aborted).toBe(true)
expect(ctx.get('workspaces')).toBeUndefined()
})
it('marks carrier loss while retrying and publishes a later protocol failure', async () => {
const ctx = new Context()
const remote = new ScriptedWorkspaceRemote([
{
frames: [baseline('old')],
error: new RemoteStreamCarrierError('generation lost'),
},
{ frames: [baseline('fresh'), baseline('duplicate')] },
])
provideClientServices(ctx, remote)
const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure')
const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure')
const fiber = ctx.plugin(WorkspaceClientPlugin)
await fiber
await waitFor(() => {
expect(ctx.workspaces.list.getSnapshot()).toMatchObject({
phase: 'ready',
state: 'error',
items: [{ workspaceId: 'fresh' }],
error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' },
})
})
expect(carrierFailure).toHaveBeenCalledOnce()
expect(streamFailure).toHaveBeenCalledOnce()
await fiber.dispose()
})
})
describe('Workspace state stream', () => {
it('delivers one baseline followed by increments', async () => {
const opening = baseline('one')
const workspace = opening.value.items[0]!
const remote = new ScriptedWorkspaceRemote([{
@@ -287,3 +435,62 @@ describe('Workspace Client snapshot adapter', () => {
await stream.dispose()
})
})
describe('WorkspaceController', () => {
it('publishes the model source and exposes successful Workspace commands', async () => {
const remote = new CommandWorkspaceRemote()
const model = new ClientWorkspaceModel(remote)
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] })
const controller = new WorkspaceController(new Context(), model)
expect(controller.list).toBe(model)
await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' })
await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' })
await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined()
await expect(controller.insertSessionBefore(wid('one'), sid('session'))).resolves.toMatchObject({
sessionIds: ['session'],
})
await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined()
await expect(controller.delete(wid('one'))).resolves.toBeUndefined()
})
it('maps generated business failures to the command facade errors', async () => {
const remote = new CommandWorkspaceRemote()
const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote))
const missingWorkspace: WorkspaceError = {
code: 'workspace-not-found',
message: 'gone',
details: { workspaceId: wid('missing') },
}
const missingSession: WorkspaceError = {
code: 'session-not-found',
message: 'missing session',
details: { sessionId: sid('session') },
}
remote.create.mockResolvedValueOnce(remoteFailure({
code: 'workspace-invalid-path',
message: 'missing path',
details: { path: '/missing' },
}))
const create = controller.create({ path: '/missing' })
await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError)
await expect(create).rejects.toThrow('workspace-invalid-path: missing path')
remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone')
remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone')
remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace))
await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone')
remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession))
await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session')
remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({
code: 'workspace-move-invalid',
message: 'invalid move',
details: { workspaceId: wid('missing'), sessionId: sid('session') },
}))
await expect(controller.insertSessionBefore(wid('missing'), sid('session')))
.rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move')
})
})
@@ -8,11 +8,15 @@
"files": [
"src/client/index.ts",
"src/client/model.ts",
"src/client/path.ts",
"src/client/service.ts",
"src/types.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../gateway/tsconfig.client.json" },
{ "path": "../../client/connection/tsconfig.client.json" },
{ "path": "../../client/store" },
{ "path": "../../core/session" },
{ "path": "../../typert/protocol" },
{ "path": "../../workspace/workspace" }