mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(workspace): move Client ownership into Workspace Controller
This commit is contained in:
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user