refactor: drop the create-by-name workspace route

The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace
Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the
whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's
workspaceRoot config/default and the mkdir branch, the client seam that carried the name
(WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web
--workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as
workspace.rename's duplicate-title error.
This commit is contained in:
creatixchu
2026-08-07 14:26:50 +08:00
parent b767ad02e0
commit a7e43d4346
44 changed files with 145 additions and 252 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md
2026-07-31-one-route-to-add-a-workspace.md: 5d002265b5eb1178bb1dbc7bd17f8b364d9b9856 2026-07-31-one-route-to-add-a-workspace.md: 853d641e0a2c0044ee7bfd6ed42bcc3763520192
2026-07-31-one-route-to-add-a-workspace.zh.md: 0a59d3a505eb921b4ec980abaefedfcad8a3c294 2026-07-31-one-route-to-add-a-workspace.zh.md: 6a2884d75138ddc241276131f7ff85080fc5d794

View File

@@ -27,7 +27,7 @@ The direct-open path carries the busy rule the menu entry states: while a pick i
## Wire and CLI residue ## Wire and CLI residue
The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. Deleted in the follow-up change this section used to scope: `workspace.create` accepts only `{ path }` (the `name` member left the wire schema and `WorkspaceApi`), the gateway lost its `workspaceRoot` config and default, the client seam narrowed to the path spelling (`WorkspaceCreateInput`, `WorkspacesService.create`, `intentName`), and the `dsh web --workspace-root` flag is gone together with its `apps/cli` reference lines. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error.
## Testing ## Testing

View File

@@ -27,7 +27,7 @@ Status: implemented
## Wire and CLI residue ## Wire and CLI residue
Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 本节曾划定的后续删除已经落地:`workspace.create` 只接受 `{ path }`(`name` 成员已从 wire schema 与 `WorkspaceApi` 移除),网关失去了 `workspaceRoot` 配置及其默认值,客户端 seam 收窄为 path 写法(`WorkspaceCreateInput`、`WorkspacesService.create`、`intentName`),`dsh web --workspace-root` flag 连同其 `apps/cli` reference 文档行一并删除。`workspace-name-conflict` 仍留在 wire 上,作为 `workspace.rename` 的重名错误。
## Testing ## Testing

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md # pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: 8b8a0e7dbebafedd6a4f8d988adb3fd11c7bd026 README.md: 9adf7e4b238a96c5497c14709ba7ddc08eff13af
README.zh.md: d1d6d5a594596a8be5db30021163f0fcea4a95bf README.zh.md: d4f2c607f1b6067c0d936761cb86301950597e1d

View File

@@ -37,7 +37,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web alias ## Web alias
`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. `dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh ```sh
dsh web dsh web

View File

@@ -37,7 +37,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
## Web 别名 ## Web 别名
`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 `dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
```sh ```sh
dsh web dsh web

View File

@@ -40,7 +40,6 @@ interface WebInvocation {
host?: string host?: string
port?: number port?: number
dev: boolean dev: boolean
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence. */ /** Extra authorities for the /api browser-trust fence. */
trustedHosts?: string[] trustedHosts?: string[]
} }
@@ -62,7 +61,6 @@ interface WebOptions {
host?: string host?: string
port?: string port?: string
dev?: boolean dev?: boolean
workspaceRoot?: string
trustedHost?: string[] trustedHost?: string[]
dumpConfig?: boolean dumpConfig?: boolean
dumpDefaultConfig?: boolean dumpDefaultConfig?: boolean
@@ -152,7 +150,6 @@ Examples:
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one') .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
@@ -172,8 +169,8 @@ Examples:
// dropping them would print a tree that differs from the same // dropping them would print a tree that differs from the same
// invocation's boot. // invocation's boot.
if (options.host !== undefined || options.port !== undefined || options.dev === true if (options.host !== undefined || options.port !== undefined || options.dev === true
|| options.workspaceRoot !== undefined || options.trustedHost !== undefined) { || options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
} }
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return return
@@ -187,7 +184,6 @@ Examples:
...options.host !== undefined && { host: options.host }, ...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) }, ...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true, dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
} }
}) })

View File

@@ -1,7 +1,7 @@
/** /**
* `dsh web` — the browser-surface alias over the profile boot: `--profile web` * `dsh web` — the browser-surface alias over the profile boot: `--profile web`
* plus the Web flag family (`--host/--port/--dev/--workspace-root/ * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
* --trusted-host`), each flag becoming a patch over the composed profile * becoming a patch over the composed profile
* tree. All web runtime glue (dist serving, prompt section, URL line) lives * tree. All web runtime glue (dist serving, prompt section, URL line) lives
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
* flag patches and the LAN-trust snapshot. * flag patches and the LAN-trust snapshot.
@@ -58,7 +58,6 @@ export interface WebFlags {
host?: string host?: string
port?: number port?: number
dev: boolean dev: boolean
workspaceRoot?: string
trustedHosts?: string[] trustedHosts?: string[]
} }
@@ -82,7 +81,6 @@ function deriveWebFlagPatches(
} }
if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.host !== undefined) put('webserver', 'host', flags.host)
if (flags.port !== undefined) put('webserver', 'port', flags.port) if (flags.port !== undefined) put('webserver', 'port', flags.port)
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
if (trustedHosts.length > 0) { if (trustedHosts.length > 0) {
@@ -118,8 +116,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
} }
/** /**
* Serve the browser UI from the web profile. Host/port/workspace-root flags * Serve the browser UI from the web profile. Host/port flags are passed
* are passed through only when given (absent, the composed profile values * through only when given (absent, the composed profile values
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
* every boot. The URL line is printed by the web-app bundle's runtime row * every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement. * after Loader settlement.

View File

@@ -29,8 +29,8 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
}) })

View File

@@ -577,18 +577,16 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
```ts config-catalog ```ts config-catalog
/** Gateway plugin config: host-level agent routing and Workspace creation root. */ /** Gateway plugin config: host-level agent routing. */
export interface Config { export interface Config {
/** Default provider route for created/resumed agents. */ /** Default provider route for created/resumed agents. */
provider: string provider: string
/** Default model id. */ /** Default model id. */
model: string model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
} }
``` ```
Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) Source: [`packages/host/apiproxy/src/index.ts:32`](../packages/host/apiproxy/src/index.ts)
## `@deepseek-ai/dsh-host-directory-picker-browse` ## `@deepseek-ai/dsh-host-directory-picker-browse`

View File

@@ -4,7 +4,7 @@
# #
# A patch replaces the targeted row's whole `config`, so each row below # A patch replaces the targeted row's whole `config`, so each row below
# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ # restates every key it owns. The `dsh web` launcher alias turns --host/--port/
# --dev/--workspace-root/--trusted-host into further patches over these rows # --dev/--trusted-host into further patches over these rows
# (`--dev` inserts the dsh-client-hmr row). # (`--dev` inserts the dsh-client-hmr row).
# ── surface-specific values the base deliberately omits ───────────────────── # ── surface-specific values the base deliberately omits ─────────────────────

View File

@@ -2122,15 +2122,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
archivedSessionIds: [...archivedSessionIds], archivedSessionIds: [...archivedSessionIds],
}), }),
create: (request) => { create: (request) => {
const { path, name } = request.payload const { path } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` const existing = workspaces.find(w => w.path === path)
const existing = workspaces.find(w => w.path === target)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString() const now = new Date().toISOString()
const created: WorkspaceView = { const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`), workspaceId: wid(`fx-ws-${nextWorkspace++}`),
path: target, path,
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, title: path.split('/').filter(Boolean).at(-1) ?? path,
sessionIds: [], sessionIds: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -546,7 +546,7 @@ describe('createFixtureApi', () => {
} }
})() })()
await new Promise(resolve => setTimeout(resolve, 10)) await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' })) const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!created.result.ok) throw new Error('create failed') if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true) expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({ expect(created.result.value.workspace).toMatchObject({
@@ -554,16 +554,7 @@ describe('createFixtureApi', () => {
}) })
await consuming await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along. // A basename-less path serves as its own title.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
const rootPath = await api.workspace.create(req({ path: '/' })) const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed') if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/') expect(rootPath.result.value.workspace.title).toBe('/')
@@ -584,7 +575,7 @@ describe('createFixtureApi', () => {
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
await api.workspace.create(req({ name: 'occupied' })) await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' }))
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
@@ -722,7 +713,7 @@ describe('createFixtureApi', () => {
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' })) const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!made.result.ok) throw new Error('workspace create failed') if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController() const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
@@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' }) const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed') if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client') expect(workspace.result.value.workspace.title).toBe('via-client')
const wsid = workspace.result.value.workspace.workspaceId const wsid = workspace.result.value.workspace.workspaceId
@@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
}) })
const client = new FixtureApiClient() const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' }) const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed') if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController() const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)

View File

@@ -27,11 +27,11 @@ export interface IWorkspaces {
*/ */
startSession(workspaceId?: WorkspaceId): void startSession(workspaceId?: WorkspaceId): void
/** /**
* Create a Workspace by name or register an existing path. * Register an existing path as a Workspace.
* @param input - exactly one Host create spelling. * @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace. * @returns the created or idempotently resolved Workspace.
*/ */
create(input: { name: string } | { path: string }): Promise<WorkspaceView> create(input: { path: string }): Promise<WorkspaceView>
/** /**
* Open the Host's native directory picker. * Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled. * @returns the selected path, or null when the user cancelled.

View File

@@ -120,7 +120,7 @@ export class WorkspaceManager {
/** /**
* Create or resolve a real Workspace, then publish its returned snapshot * Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame. * without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path. * @param input - the existing absolute path to adopt.
* @returns the wire result. * @returns the wire result.
*/ */
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> { async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {

View File

@@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces {
} }
/** /**
* Create a Workspace by name or register an existing path. * Register an existing path as a Workspace.
* @param input - exactly one Host create spelling. * @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace. * @returns the created or idempotently resolved Workspace.
*/ */
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> { async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input) const result = await this.manager.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error) if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace return result.value.workspace

View File

@@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts' import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */ /** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string } export type WorkspaceCreateInput = { path: string }
/** Observable state of a client-local Workspace intent. */ /** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot { export interface WorkspaceIntentSnapshot {
@@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
} }
function intentName(input: WorkspaceCreateInput): string { function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '') const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path return trimmed.split(/[\\/]/).pop() ?? input.path
} }

View File

@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
}) })
it('creates by name/path, prepends a new row, and folds failures', async () => { it('creates by path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient() const api = new FakeApiClient()
const manager = new WorkspaceManager(api) const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({ api.onWorkspaceCreate = payload => Promise.resolve(ok({
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
created: true, created: true,
payload, payload,
} as never)) } as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))

View File

@@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces {
/** /**
* Create a Workspace (recorded). The default echoes a view derived from * Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows. * the input; stub for failure or list-coupled flows.
* @param input - exactly one Host create spelling. * @param input - the Host create payload.
* @returns the created Workspace view. * @returns the created Workspace view.
*/ */
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> { async create(input: { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] }) this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create') const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>) if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
const title = 'name' in input ? input.name : input.path
return { return {
workspaceId: `ws-${title}` as WorkspaceId, workspaceId: `ws-${input.path}` as WorkspaceId,
title, title: input.path,
path: 'path' in input ? input.path : `/${input.name}`, path: input.path,
sessionIds: [], sessionIds: [],
} as unknown as WorkspaceView } as unknown as WorkspaceView
} }

View File

@@ -559,8 +559,8 @@ describe('workspaces action face', () => {
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
const runtime = await SlotTestRuntime.create() const runtime = await SlotTestRuntime.create()
const ws = runtime.workspaces const ws = runtime.workspaces
const created = await ws.create({ name: 'alpha' }) const created = await ws.create({ path: '/tmp/alpha' })
expect(created.title).toBe('alpha') expect(created.title).toBe('/tmp/alpha')
const registered = await ws.create({ path: '/tmp/beta' }) const registered = await ws.create({ path: '/tmp/beta' })
expect(registered.path).toBe('/tmp/beta') expect(registered.path).toBe('/tmp/beta')
await expect(ws.pickDirectory()).resolves.toBeNull() await expect(ws.pickDirectory()).resolves.toBeNull()
@@ -584,7 +584,7 @@ describe('workspaces action face', () => {
ws.stub('openPath', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve())
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve()) ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X') expect((await ws.create({ path: '/y' })).title).toBe('X')
await expect(ws.pickDirectory()).resolves.toBe('/picked') await expect(ws.pickDirectory()).resolves.toBe('/picked')
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId) await ws.delete('w1' as WorkspaceId)

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5 README.md: 1caf9f2ee61fbf36a18b18ff2d1e7e230ee4b7f6
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9 README.zh.md: c42312bb9972c4c5b02ec4dbc5c7d4402921a9c1

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml). The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml).
## Contract layer (`/api`) ## Contract layer (`/api`)
@@ -24,7 +24,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。 所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`packages/bundle/base/cordis.patch.yml`](../../bundle/base/cordis.patch.yml)。
## 契约层(`/api`) ## 契约层(`/api`)
@@ -24,7 +24,7 @@
待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。

View File

@@ -5,7 +5,6 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises' import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -333,8 +332,6 @@ export interface ApiProxyDefaults {
model: string model: string
/** Default project directory for new sessions whose create request carries no cwd. */ /** Default project directory for new sessions whose create request carries no cwd. */
cwd: string cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native open-with-default-application; injectable for carrier tests. */ /** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void> openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */ /** Native text-editor handoff; injectable for settings-document tests. */
@@ -668,9 +665,6 @@ class SessionCwdConflict extends Error {
} }
} }
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** An explicit Host naming operation would duplicate another Workspace title. */ /** An explicit Host naming operation would duplicate another Workspace title. */
class WorkspaceNameConflictError extends Error { class WorkspaceNameConflictError extends Error {
constructor(readonly workspaceName: string) { constructor(readonly workspaceName: string) {
@@ -1183,29 +1177,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} }
/** Resolve or create one path while holding the Host's workspace-create chain. */ /** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace( function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
path: string,
title: string | undefined,
rejectExistingName = false,
createDirectory = false,
): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => { const operation = workspaceCreationChain.then(async () => {
if (rejectExistingName && title !== undefined
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
throw new WorkspaceNameConflictError(title)
}
if (createDirectory) {
try {
await mkdir(path, { recursive: true })
} catch (error: unknown) {
throw new WorkspaceDirectoryCreationError(
`failed to create workspace directory "${path}": ${String(error)}`,
)
}
}
const existing = await ctx.workspace.resolveByPath(path) const existing = await ctx.workspace.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false } if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path, title), created: true } return { workspace: await ctx.workspace.create(path), created: true }
}) })
workspaceCreationChain = operation.then(() => undefined, () => undefined) workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation return operation
@@ -2035,54 +2011,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})) }))
}, },
// Exactly one of path/name arrives (schema refine). Existing-folder
// adoption reuses its canonical path; create-by-name rejects a name
// already present in the registry.
// TODO: the create-by-name branch lost its last product consumer when
// the Web picker collapsed onto the directory flow
// (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
// Delete it with the wire schema's `name` member, this
// `defaults.workspaceRoot`, the client seam that carried the name
// (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
// `intentName`'s name branch, the manager's "name under workspaceRoot"
// contract), and the `dsh web --workspace-root` flag plus its apps/cli
// README lines, which exist only to feed it.
async create(request) { async create(request) {
const { payload } = request const { path } = request.payload
let path: string
if (payload.name !== undefined) {
const name = payload.name.trim()
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
return err(request, {
code: 'workspace-invalid-path',
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
details: { path: payload.name },
})
}
path = join(defaults.workspaceRoot, name)
} else {
path = payload.path as string
}
try { try {
const name = payload.name?.trim() const { workspace, created } = await ensureWorkspace(path)
const { workspace, created } = await ensureWorkspace(
path,
name,
name !== undefined,
name !== undefined,
)
return ok(request, { workspace: workspaceView(workspace), created }) return ok(request, { workspace: workspaceView(workspace), created })
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
if (error instanceof WorkspaceDirectoryCreationError) {
return err(request, { code: 'internal', message: error.message, details: {} })
}
// The registry rejects a path that does not resolve to an existing // The registry rejects a path that does not resolve to an existing
// directory (realpath ENOENT / not-a-directory) — the business // directory (realpath ENOENT / not-a-directory) — the business
// error of the typed-path flow, surfaced as a validation failure. // error of the typed-path flow, surfaced as a validation failure.

View File

@@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({
archivedSessionIds: z.array(sessionIdSchema), archivedSessionIds: z.array(sessionIdSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>> }) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ /** workspace.create request payload: the existing directory to adopt. */
export const workspaceCreateRequestSchema = z.object({ export const workspaceCreateRequestSchema = z.object({
path: z.string().optional(), path: z.string(),
name: z.string().optional(), }) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
}).refine(
payload => (payload.path === undefined) !== (payload.name === undefined),
{ message: 'workspace.create requires exactly one of path / name' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
/** workspace.create response value. */ /** workspace.create response value. */
export const workspaceCreateValueSchema = z.object({ export const workspaceCreateValueSchema = z.object({

View File

@@ -46,19 +46,14 @@ export interface WorkspaceApi {
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>> list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
/** /**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` / * Creates (or idempotently resolves) a workspace over an EXISTING directory
* `name` (schema-enforced): `path` registers an EXISTING directory (no * (no mkdir — a missing or non-directory path fails with
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`); * `workspace-invalid-path`). A path resolving to a directory already owned
* `name` is a single path segment the host mkdirs under its default project * by a workspace returns that workspace (`created: false`). Adoption allows
* root before registering. Either spelling resolving to a directory already * distinct canonical paths whose basenames produce the same display title;
* owned by a workspace returns that workspace (`created: false`) for the * the registry's basename title default names the new workspace.
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; path adoption allows distinct canonical paths
* whose basenames produce the same display title.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
*/ */
create(request: RpcRequest<{ path?: string; name?: string }>): create(request: RpcRequest<{ path: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
/** /**

View File

@@ -8,7 +8,6 @@
* routes — physical carriers wrap `ctx.apiProxy` themselves. * routes — physical carriers wrap `ctx.apiProxy` themselves.
*/ */
import { resolve } from 'node:path'
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { ApiProxy } from './api/index.ts' import type { ApiProxy } from './api/index.ts'
@@ -29,20 +28,18 @@ declare module 'cordis' {
} }
} }
/** Gateway plugin config: host-level agent routing and Workspace creation root. */ /** Gateway plugin config: host-level agent routing. */
export interface Config { export interface Config {
/** Default provider route for created/resumed agents. */ /** Default provider route for created/resumed agents. */
provider: string provider: string
/** Default model id. */ /** Default model id. */
model: string model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
} }
/** /**
* The API gateway service: implements the ApiProxy contract over the composed * The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default * host context and provides it as `ctx.apiProxy`. The Host cwd is the default
* project directory and the fallback parent for name-created Workspaces. * project directory.
*/ */
export class ApiProxyService extends Service implements ApiProxy { export class ApiProxyService extends Service implements ApiProxy {
static inject = [ static inject = [
@@ -53,7 +50,6 @@ export class ApiProxyService extends Service implements ApiProxy {
static Config: z<Config> = z.object({ static Config: z<Config> = z.object({
provider: z.string().required(), provider: z.string().required(),
model: z.string().required(), model: z.string().required(),
workspaceRoot: z.string(),
}) })
readonly sessions: ApiProxy['sessions'] readonly sessions: ApiProxy['sessions']
@@ -71,12 +67,10 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) { constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy') super(ctx, 'apiProxy')
const cwd = process.cwd()
const api = createApiProxy(ctx, { const api = createApiProxy(ctx, {
provider: config.provider, provider: config.provider,
model: config.model, model: config.model,
cwd, cwd: process.cwd(),
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
}) })
this.sessions = api.sessions this.sessions = api.sessions
this.subagents = api.subagents this.subagents = api.subagents

View File

@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService) await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService) await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
return { ctx, api } return { ctx, api }
} }
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
await ctx.plugin(ApprovalService) await ctx.plugin(ApprovalService)
let api!: ApiProxy let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await() await fiber.await()
const abort = new AbortController() const abort = new AbortController()

View File

@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
return { return {
ctx, ctx,
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }),
attach: (session) => { attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
}, },

View File

@@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => {
return undefined return undefined
}, },
}) })
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.list(request({})) const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true) expect(response.result.ok).toBe(true)
@@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => {
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService) await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup. // Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000 const worked = 1_000_000
@@ -148,7 +148,7 @@ describe('cold history recovery view', () => {
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined, locate: () => undefined,
} as never) } as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed') if (!history.result.ok) throw new Error('history failed')
@@ -216,7 +216,7 @@ describe('subagent ownership fence', () => {
locate: () => undefined, locate: () => undefined,
} as never) } as never)
const resume = vi.spyOn(ctx.agents, 'resume') const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId })) const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true) expect(history.result.ok).toBe(true)
@@ -275,7 +275,7 @@ describe('subagent ownership fence', () => {
// instead of answering `agent-busy`. // instead of answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume') const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench')) .mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const prompt = await api.sessions.prompt(request({ const prompt = await api.sessions.prompt(request({
sessionId, sessionId,
@@ -316,7 +316,7 @@ describe('subagent ownership fence', () => {
}) })
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent) ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false) expect(stopped.result.ok).toBe(false)
@@ -362,7 +362,7 @@ describe('subagent ownership fence', () => {
const followup = vi.fn() const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent) ctx.agents.register(agent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.prompt(request({ const response = await api.sessions.prompt(request({
sessionId: agent.id, sessionId: agent.id,
@@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService) await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const listed = await api.sessions.list(request({})) const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true) expect(listed.result.ok).toBe(true)
@@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
list: () => Promise.resolve([]), list: () => Promise.resolve([]),
inspect, inspect,
} as never) } as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false) expect(response.result.ok).toBe(false)
@@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent) } as unknown as Agent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) { for (const mode of ['queue', 'steer'] as const) {
const response = await api.sessions.prompt(request({ const response = await api.sessions.prompt(request({
@@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register(child) ctx.agents.register(child)
throw new Error('session id already published') throw new Error('session id already published')
}) })
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const models = await api.sessions.models(request({ sessionId })) const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false) expect(models.result.ok).toBe(false)

View File

@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts' import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' }
function request<P>(payload: P): RpcRequest<P> { function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

View File

@@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts' import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp' }
let nextRpc = 1 let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> { function request<P>(payload: P): RpcRequest<P> {

View File

@@ -85,7 +85,6 @@ const api = (ctx: Context) => createApiProxy(ctx, {
provider: 'default-provider', provider: 'default-provider',
model: 'default-model', model: 'default-model',
cwd: '/tmp', cwd: '/tmp',
workspaceRoot: '/tmp',
}) })
describe('sessions.fork', () => { describe('sessions.fork', () => {

View File

@@ -125,7 +125,7 @@ describe('Web session model selection', () => {
model: 'private-preview', model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'), reasoningEffort: ReasoningEffortId('max'),
}) })
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId }))) const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({ expect(catalog.current).toEqual({
@@ -160,7 +160,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness() const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal const signal = new AbortController().signal

View File

@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
} }
} }
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
describe('session.history projections block', () => { describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

View File

@@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService) await ctx.plugin(UserInteractionService)
return { return {
ctx, ctx,
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }),
} }
} }

View File

@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session return session
} }
const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
describe('sessions.rename', () => { describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

View File

@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
}) })
const sid = (value: string): SessionId => value as SessionId const sid = (value: string): SessionId => value as SessionId
const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } const defaults = { provider: 'p', model: 'm', cwd: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> { function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } } return { rpcId: RpcId(`search-${query}`), payload: { query } }

View File

@@ -88,7 +88,7 @@ function bench(options: {
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, { const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp', provider: 'p', model: 'm', cwd: '/tmp',
}) })
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
} }

View File

@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
describe('mux live view computation', () => { describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController() const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort) const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const session = ctx.sessions.create() const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only // history resolves the agent first; a live structural stub is enough (only
// .session is read on this path). // .session is read on this path).
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => { it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const session = ctx.sessions.create() const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 1 })
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController() const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
it('pairs a result after turn/end via the in-memory backscan fallback', async () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness() const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController() const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort) const collected = collect(stream, 4, abort)

View File

@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness( async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {}, extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) { ) {
@@ -102,11 +102,17 @@ async function harness(
const api = createApiProxy(ctx, { const api = createApiProxy(ctx, {
provider: 'test', provider: 'test',
model: 'test-model', model: 'test-model',
cwd: workspaceRoot, cwd: root,
workspaceRoot,
...extras.openPath === undefined ? {} : { openPath: extras.openPath }, ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
}) })
return { api, ctx, storageDomain, workspaceRoot } return { api, ctx, storageDomain, root }
}
/** Stage one directory under the harness root for path adoption. */
function stageDir(root: string, name: string): string {
const path = join(root, name)
mkdirSync(path)
return path
} }
describe('host.pickDirectory', () => { describe('host.pickDirectory', () => {
@@ -244,30 +250,26 @@ describe('host.openPath', () => {
}) })
describe('workspace.create', () => { describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => { it('serializes concurrent creates of one path into a single registration', async () => {
const { api, workspaceRoot } = await harness() const { api, root } = await harness()
const target = join(root, 'alpha')
mkdirSync(target)
const responses = await Promise.all([ const responses = await Promise.all([
api.workspace.create(request({ name: 'alpha' })), api.workspace.create(request({ path: target })),
api.workspace.create(request({ name: 'alpha' })), api.workspace.create(request({ path: target })),
]) ])
const created = responses.find(response => response.result.ok) const values = responses.map(response => expectOk(response))
const duplicate = responses.find(response => !response.result.ok) const created = values.find(value => value.created)
const resolved = values.find(value => !value.created)
expect(created).toBeDefined() expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
expect(expectOk(created!)).toMatchObject({ expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
created: true, expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
})
expect(duplicate?.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
})
expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
}) })
it('adopts only existing directories and rejects unsafe names', async () => { it('adopts only existing directories', async () => {
const { api, workspaceRoot } = await harness() const { api, root } = await harness()
const existing = join(workspaceRoot, 'existing') const existing = join(root, 'existing')
mkdirSync(existing) mkdirSync(existing)
const first = expectOk(await api.workspace.create(request({ path: existing }))) const first = expectOk(await api.workspace.create(request({ path: existing })))
const repeated = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing })))
@@ -281,21 +283,16 @@ describe('workspace.create', () => {
const reopened = expectOk(await api.workspace.create(request({ path: existing }))) const reopened = expectOk(await api.workspace.create(request({ path: existing })))
expect(reopened.workspace.title).toBe('renamed-existing') expect(reopened.workspace.title).toBe('renamed-existing')
const missing = join(workspaceRoot, 'missing') const missing = join(root, 'missing')
const missingResult = await api.workspace.create(request({ path: missing })) const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
expect(existsSync(missing)).toBe(false) expect(existsSync(missing)).toBe(false)
for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
const invalid = await api.workspace.create(request({ name }))
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
}) })
it('adopts different paths that derive the same Workspace title', async () => { it('adopts different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness() const { api, root } = await harness()
const first = join(workspaceRoot, 'one', 'project') const first = join(root, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project') const second = join(root, 'two', 'project')
mkdirSync(first, { recursive: true }) mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true }) mkdirSync(second, { recursive: true })
const firstResult = expectOk(await api.workspace.create(request({ path: first }))) const firstResult = expectOk(await api.workspace.create(request({ path: first })))
@@ -316,8 +313,8 @@ describe('workspace.create', () => {
describe('session creation and Workspace membership', () => { describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
const { api, ctx } = await harness() const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const sessionId = SessionId('session-workspace-preallocated') const sessionId = SessionId('session-workspace-preallocated')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -343,8 +340,8 @@ describe('session creation and Workspace membership', () => {
}) })
it('retains a published session when attachment fails and repairs it on retry', async () => { it('retains a published session when attachment fails and repairs it on retry', async () => {
const { api, ctx } = await harness() const { api, ctx, root } = await harness()
const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const workspace = ctx.workspace.list()[0] const workspace = ctx.workspace.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry') if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
@@ -394,7 +391,7 @@ describe('Host Workspace increments', () => {
}) })
it('streams committed Workspace and Session increments after empty baselines', async () => { it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness() const { api, root } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
@@ -402,7 +399,7 @@ describe('Host Workspace increments', () => {
const stream: AsyncIterator<RpcRequest<HostFrame>> = const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream) const workspaceIncrement = nextHostFrame(stream)
const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
expect(await workspaceIncrement).toMatchObject({ expect(await workspaceIncrement).toMatchObject({
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
}) })
@@ -430,7 +427,7 @@ describe('Host Workspace increments', () => {
}) })
it('does not publish a Workspace whose registry-order commit fails', async () => { it('does not publish a Workspace whose registry-order commit fails', async () => {
const { api, storageDomain } = await harness() const { api, storageDomain, root } = await harness()
const domain = storageDomain.get('workspace') const domain = storageDomain.get('workspace')
if (domain === undefined) throw new Error('workspace domain is not open') if (domain === undefined) throw new Error('workspace domain is not open')
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
@@ -439,7 +436,7 @@ describe('Host Workspace increments', () => {
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next() const next = stream.next()
const failed = await api.workspace.create(request({ name: 'ghost' })) const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
expect(failed.result.ok).toBe(false) expect(failed.result.ok).toBe(false)
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
abort.abort() abort.abort()
@@ -447,8 +444,8 @@ describe('Host Workspace increments', () => {
}) })
it('deletes the registration, keeps its session and folder, and streams one removal', async () => { it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
const { api, ctx } = await harness() const { api, ctx, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
const sessionId = SessionId('session-kept-after-workspace-delete') const sessionId = SessionId('session-kept-after-workspace-delete')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -480,8 +477,8 @@ describe('Host Workspace increments', () => {
}) })
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
const { api } = await harness() const { api, root } = await harness()
const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
const sessionId = SessionId('session-to-archive') const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])

View File

@@ -378,8 +378,8 @@ describe('workspace domain round trip', () => {
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
}) })
it('rejects a create payload violating the exactly-one refine at the handler', async () => { it('rejects a pathless create payload at the handler schema', async () => {
const response = await client(scriptedApi()).workspace.create({}) const response = await client(scriptedApi()).workspace.create({} as never)
expect(response.result.ok).toBe(false) expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
}) })

View File

@@ -324,11 +324,9 @@ describe('workspace domain schemas', () => {
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
}) })
it('create requires exactly one of path/name (both refine arms)', () => { it('create requires a path', () => {
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
}) })

View File

@@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
const session = ctx.sessions.create() const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
return { return {
ctx, ctx,
session, session,