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