diff --git a/knip.json b/knip.json index e5e331194b..b08fe22a25 100644 --- a/knip.json +++ b/knip.json @@ -76,11 +76,6 @@ "tests/**/*.ts" ] }, - "packages/api/workspace-controller": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/client/ui-approval": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 29245fef86..bc045f38c7 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -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:^", diff --git a/packages/api/workspace-controller/src/directory-picker.ts b/packages/api/workspace-controller/src/directory-picker.ts new file mode 100644 index 0000000000..ee58ef7b83 --- /dev/null +++ b/packages/api/workspace-controller/src/directory-picker.ts @@ -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 { + 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 { + 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 { + 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: 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: 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) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0fbd514cd5..40ab0647f2 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -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) } /** diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f530e2ef12..552a9e20c3 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -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 + /** A backend failure with no seam code of its own. */ + internal: Record +} + /** Existing directory requested for Workspace adoption. */ export interface WorkspaceCreateRequest { readonly path: string diff --git a/packages/api/workspace-controller/tests/directory-picker.host.spec.ts b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts new file mode 100644 index 0000000000..32fd2dd6a1 --- /dev/null +++ b/packages/api/workspace-controller/tests/directory-picker.host.spec.ts @@ -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): 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' } }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json index 46d392dff3..9979b769f6 100644 --- a/packages/api/workspace-controller/tsconfig.client.json +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -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" } ] diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 76c584fd01..4b98892781 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -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" }, diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index adbe6d09ad..01fe5b9cd2 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -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`) } diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index abf29856fc..5c44aac9af 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -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 = () => {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcc43f58e1..80cb2119f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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