feat(workspace-controller): expose directory picking through Remote

This commit is contained in:
imccyu
2026-08-27 02:20:38 +08:00
parent 3007864cfe
commit 76dedd4862
11 changed files with 383 additions and 9 deletions
-5
View File
@@ -76,11 +76,6 @@
"tests/**/*.ts"
]
},
"packages/api/workspace-controller": {
"ignoreDependencies": [
"zod"
]
},
"packages/client/ui-approval": {
"entry": [
"tests/**/*.spec.tsx"
@@ -76,6 +76,7 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
@@ -87,6 +88,7 @@
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
@@ -0,0 +1,171 @@
/**
* Host directory-picking Remote owner: capability gating, cancellation, and the
* stable wire failure vocabulary over the `ctx.directoryPicker` seam.
*/
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapabilities } from '@deepseek-ai/dsh-host-directory-picker'
// The seam owns the listing declaration; the generator requires the reference
// site to name that package rather than this package's re-export of it.
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { DirectoryPickerErrorDetailsMap } from './types.ts'
const createDirectoryRequestSchema = z.object({
path: z.string(),
name: z.string(),
}).refine(
request => request.name.trim() !== '' && request.name !== '.' && request.name !== '..'
&& !/[/\\]/.test(request.name),
{ message: 'host.createDirectory requires a single non-blank path segment name' },
)
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host directory-picking Remote namespace owner. */
directoryPickerController: DirectoryPickerController
}
}
/**
* Host service backing the generated `ctx.remote.directoryPicker` namespace. The
* seam it exports is abstract and therefore never a Loader entry of its own, so
* this controller carries the wire verbs: one composed backend serves either the
* native chooser or the browse primitives, and a verb the composition cannot
* serve is refused rather than approximated.
*/
export class DirectoryPickerController extends TypertRemoteService {
static inject = ['directoryPicker']
/** @param ctx - Host context carrying the composed directory-picking backend. */
constructor(ctx: Context) {
super(ctx, 'directoryPickerController', { namespace: 'directoryPicker' })
}
/**
* Open the host's OS chooser for a Remote caller.
* @param signal - caller lifetime; abort terminates the chooser.
* @returns the chosen absolute path, or null when the operator cancels.
*/
@Remote('pick')
async pick(signal: AbortSignal): Promise<string | null> {
const capability = this.requireCapability('native', 'pick')
try {
return await capability.pick(signal)
} catch (error: unknown) {
throw cancellableFailure(error, signal, 'directory picker was aborted', 'directory picker failed')
}
}
/**
* List one directory level for a Remote caller's in-app browser.
* @param path - absolute directory to list; absent lists the home directory.
* @param signal - caller lifetime; abort stops the backend's scan instead of
* letting it outlive a disconnected caller.
* @returns the level's listing with its ancestry.
*/
@Remote('list')
async list(path: string | undefined, signal: AbortSignal): Promise<DirectoryListing> {
const capability = this.requireCapability('browse', 'list')
try {
return await capability.list(path, signal)
} catch (error: unknown) {
throw cancellableFailure(error, signal, 'directory listing was aborted')
}
}
/**
* Create one child directory for a Remote caller's in-app browser.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
@Remote('createDirectory')
async createDirectory(path: string, name: string): Promise<string> {
const request = createDirectoryRequestSchema.safeParse({ path, name })
if (!request.success) {
throw pickerFailureOf(
'bad-request',
'invalid payload for host.createDirectory',
{ issues: request.error.issues },
)
}
const capability = this.requireCapability('browse', 'createDirectory')
try {
return await capability.createDirectory(request.data.path, request.data.name)
} catch (error: unknown) {
throw browseFailure(error)
}
}
/** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */
private requireCapability<Kind extends keyof DirectoryPickerCapabilities>(
kind: Kind,
method: string,
): DirectoryPickerCapabilities[Kind] {
const capability = this.ctx.directoryPicker.capability()
if (capability.kind !== kind) {
throw pickerFailureOf(
'directory-picker-unavailable',
`directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`,
{ capability: capability.kind },
)
}
return capability as DirectoryPickerCapabilities[Kind]
}
}
/**
* Raise one entry of the picking wire failure vocabulary.
* @param code - the failure code a caller discriminates on.
* @param message - operator-facing description.
* @param details - the payload this code carries.
* @returns the failure to throw across the Remote boundary.
*/
function pickerFailureOf<Code extends keyof DirectoryPickerErrorDetailsMap>(
code: Code,
message: string,
details: DirectoryPickerErrorDetailsMap[Code],
): TypertRemoteFailure {
return new TypertRemoteFailure({ code, message, details })
}
/**
* Classify a browse-primitive rejection: the seam's own closed codes carry the
* path they are about, and anything else stays an infrastructure failure.
* @param error - the primitive's rejection.
* @returns the failure to throw across the Remote boundary.
*/
function browseFailure(error: unknown): TypertRemoteFailure {
if (error instanceof DirectoryPickerError) {
return pickerFailureOf(error.code, error.message, { path: error.path })
}
return pickerFailureOf('internal', errorMessage(error), {})
}
/**
* Classify a cancellable primitive's rejection. An abort is the caller's own
* timeout or disconnect, not a backend failure, so it answers `cancelled`
* before the business classification runs.
* @param error - the primitive's rejection.
* @param signal - the caller lifetime the primitive ran under.
* @param cancelled - operator-facing text for the abort outcome.
* @param failed - prefix for a non-seam failure, when the verb has no closed codes.
* @returns the failure to throw across the Remote boundary.
*/
function cancellableFailure(
error: unknown,
signal: AbortSignal,
cancelled: string,
failed?: string,
): TypertRemoteFailure {
if (signal.aborted) return pickerFailureOf('cancelled', cancelled, {})
if (failed === undefined) return browseFailure(error)
return pickerFailureOf('internal', `${failed}: ${errorMessage(error)}`, {})
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -3,6 +3,7 @@
import { Context } from '@deepseek-ai/cordis'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { WorkspaceCommands } from './commands.ts'
import { DirectoryPickerController } from './directory-picker.ts'
import { WorkspaceFeed } from './feed.ts'
import type {
WorkspaceArchiveSessionRequest,
@@ -20,6 +21,7 @@ import type {
} from './types.ts'
export type * from './types.ts'
export { DirectoryPickerController } from './directory-picker.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -40,6 +42,11 @@ export class WorkspaceController extends TypertRemoteService {
super(ctx, 'workspaceController', { namespace: 'workspace' })
this.commands = new WorkspaceCommands(ctx)
this.feed = new WorkspaceFeed(ctx)
// This package is the Loader entry for both Remote owners it hosts: the
// directory-picking seam is abstract and never an entry itself. The child
// stays pending until a picking backend is composed, so a host without one
// registers no picking namespace instead of answering an unservable verb.
ctx.plugin(DirectoryPickerController)
}
/**
+28 -1
View File
@@ -1,9 +1,18 @@
/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */
/**
* Browser-safe request, result, and state-stream vocabulary for the Workspace
* and directory-picking Remote namespaces this package owns. The picking seam
* declares its own listing types, so they are re-exported here rather than
* restated: a browser consumer reads the very declaration the backend answers.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
/** One durable Workspace projected for browser consumers. */
export interface WorkspaceView {
@@ -43,6 +52,24 @@ export type WorkspaceError = {
}
}[keyof WorkspaceErrorDetailsMap]
/** Stable directory-picking failure details returned by the picking wire verbs. */
export interface DirectoryPickerErrorDetailsMap {
/** The directory creation request violates its semantic input constraints. */
'bad-request': { readonly issues: ZodIssue[] }
/** The verb needs an interaction the composed backend does not serve. */
'directory-picker-unavailable': { readonly capability: string }
/** The target is not fully qualified, or the backend cannot list it. */
'directory-unreadable': { readonly path: string }
/** A child of that name is already there. */
'directory-exists': { readonly path: string }
/** The parent is not fully qualified, the name is not one segment, or creation failed. */
'directory-create-failed': { readonly path: string }
/** The caller's own timeout or disconnect ended the chooser or the scan. */
cancelled: Record<never, never>
/** A backend failure with no seam code of its own. */
internal: Record<never, never>
}
/** Existing directory requested for Workspace adoption. */
export interface WorkspaceCreateRequest {
readonly path: string
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { DirectoryPickerController } from '../src/directory-picker.ts'
const roots: Context[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
})
/** A backend serving exactly the capability one case is about. */
class StubPicker extends DirectoryPicker {
static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
capability(): DirectoryPickerCapability {
return StubPicker.capabilityStub
}
}
const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
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')
if (name === 'gone') throw 'the volume vanished'
return `${path}/${name}`
},
}
async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) {
StubPicker.capabilityStub = capability
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(StubPicker).await()
return new DirectoryPickerController(ctx)
}
/** The failure payload a refused wire verb carries. */
async function refused(call: Promise<unknown>): Promise<{ code: string; message: string; details: object }> {
try {
await call
} catch (error: unknown) {
if (!(error instanceof TypertRemoteFailure)) throw error
return { ...error.failure }
}
throw new Error('the call was expected to be refused')
}
describe('directoryPicker pick Remote', () => {
it('answers the selected path or the operator\'s cancellation', async () => {
const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' })
expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project')
const cancelled = await harness(NATIVE_STUB)
expect(await cancelled.pick(new AbortController().signal)).toBeNull()
})
it('reports an aborted chooser as cancelled and any other failure as internal', async () => {
const picker = await harness({
kind: 'native',
pick: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
const abort = new AbortController()
const pending = refused(picker.pick(abort.signal))
abort.abort()
expect((await pending).code).toBe('cancelled')
const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
const failure = await refused(broken.pick(new AbortController().signal))
expect(failure.code).toBe('internal')
expect(failure.message).toContain('no chooser installed')
})
it('refuses the native verb under a browse composition', async () => {
const picker = await harness(BROWSE_STUB)
const failure = await refused(picker.pick(new AbortController().signal))
expect(failure.code).toBe('directory-picker-unavailable')
expect(failure.message).toContain('needs the native capability')
expect(failure.details).toEqual({ capability: 'browse' })
})
})
describe('directoryPicker browse Remotes', () => {
it('serves listings and creation, defaulting to the home directory', async () => {
const picker = await harness(BROWSE_STUB)
const signal = new AbortController().signal
expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' })
expect(await picker.list('/home/user/projects', signal))
.toMatchObject({ path: '/home/user/projects' })
expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh')
})
it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
const picker = await harness(BROWSE_STUB)
expect(await refused(picker.list('/denied', new AbortController().signal)))
.toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } })
expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists')
expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal')
const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' })
})
it('rejects invalid child names before capability dispatch', async () => {
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
const picker = await harness({
kind: 'browse',
list: (path, signal) => BROWSE_STUB.list(path, signal),
createDirectory,
})
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
const failure = await refused(picker.createDirectory('/home/user', name))
expect(failure).toMatchObject({
code: 'bad-request',
message: 'invalid payload for host.createDirectory',
})
expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
}
expect(createDirectory).not.toHaveBeenCalled()
})
it('reports an aborted listing as cancelled', async () => {
const picker = await harness({
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 = refused(picker.list(undefined, abort.signal))
abort.abort()
expect((await pending).code).toBe('cancelled')
})
it('refuses the browse verbs under a native composition', async () => {
const picker = await harness()
expect(await refused(picker.list(undefined, new AbortController().signal)))
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
expect(await refused(picker.createDirectory('/x', 'y')))
.toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
})
})
@@ -17,6 +17,7 @@
{ "path": "../../client/connection/tsconfig.client.json" },
{ "path": "../../client/store" },
{ "path": "../../core/session" },
{ "path": "../../host/directory-picker" },
{ "path": "../../typert/protocol" },
{ "path": "../../workspace/workspace" }
]
@@ -10,11 +10,13 @@
"src/invariant.ts",
"src/types.ts",
"src/commands.ts",
"src/directory-picker.ts",
"src/feed.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/session" },
{ "path": "../../host/directory-picker" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../storage/storage-domain" },
{ "path": "../../typert/protocol" },
@@ -303,8 +303,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
}
const parent = resolve(path)
// The backend owns segment validation (the wire schema also refuses these,
// but direct service consumers must hit the same fence).
// The backend owns segment validation; the Remote controller also refuses
// invalid wire input, but direct service consumers must hit the same fence.
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
}
@@ -12,7 +12,7 @@ export const inject = ['invariants']
/**
* No runtime invariant: this stateless Service Definition owns the capability
* vocabulary, while backends and the RPC consumer own observations.
* vocabulary, while backends and the Remote controller own observations.
*/
const install: InvariantInstaller = () => {}
+3
View File
@@ -838,6 +838,9 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../../client/store
'@deepseek-ai/dsh-host-directory-picker':
specifier: workspace:^
version: link:../../host/directory-picker
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants