Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/en/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,7 @@ Workspaces are the registered project directories sessions live in. These endpoi
| `GET /api/v1/workspaces/{workspace_id}/trust` | Read the trust state |
| `POST /api/v1/workspaces/{workspace_id}/trust` | Grant trust |
| `POST /api/v1/workspaces/{workspace_id}/untrust` | Revoke trust |
| `POST /api/v1/workspaces/{workspace_id}/add-dir` | Add an additional directory |

#### The workspace object

Expand Down Expand Up @@ -1660,6 +1661,22 @@ On success, `data` is `{ trusted: false }`.

- `40410`: workspace not found

#### `POST /api/v1/workspaces/{workspace_id}/add-dir`

Adds an additional directory to the workspace, with the same semantics as the CLI `--add-dir` flag and the TUI `/add-dir` command. The path accepts absolute paths, relative paths (resolved against the workspace root), and `~` expansion.

| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `workspace_id` | path | string | **Required.** Workspace id |
| `path` | body | string | **Required.** Directory to add |
| `persist` | body | boolean | Defaults to `true`: appends to `workspace.additional_dir` in `<project root>/.kimi-code/local.toml`. With `false`, the directory only joins the in-memory ephemeral set shared by all sessions of the workspace |

On success, `data` is `{ project_root, config_path, additional_dirs, persisted }`, where `additional_dirs` lists every additional directory (existing ones included) and `persisted` reports whether this call wrote to disk.

- `40001`: validation failure (`details` lists each field), or an engine-side config validation error such as a corrupted project local config
- `40409`: `path` does not exist or is not a directory
- `40410`: workspace not found

### File system

In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{action}` with JSON bodies; actions are `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`. Every action body also accepts an optional `runtime_id` (string, default `local`) selecting the runtime that executes the operation; `open`, `open-in`, and `reveal` only work on the `local` runtime. In addition:
Expand Down
17 changes: 17 additions & 0 deletions docs/zh/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳
| `GET /api/v1/workspaces/{workspace_id}/trust` | 读取信任状态 |
| `POST /api/v1/workspaces/{workspace_id}/trust` | 授予信任 |
| `POST /api/v1/workspaces/{workspace_id}/untrust` | 撤销信任 |
| `POST /api/v1/workspaces/{workspace_id}/add-dir` | 添加附加目录 |

#### workspace 对象

Expand Down Expand Up @@ -1660,6 +1661,22 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳

- `40410`:工作区不存在

#### `POST /api/v1/workspaces/{workspace_id}/add-dir`

为工作区添加附加目录,语义与 CLI `--add-dir` 及 TUI `/add-dir` 一致。路径支持绝对路径、相对路径(相对工作区根目录解析)与 `~` 展开。

| 参数 | 位置 | 类型 | 说明 |
| --- | --- | --- | --- |
| `workspace_id` | path | string | **必填。** 工作区 id |
| `path` | body | string | **必填。** 要添加的目录 |
| `persist` | body | boolean | 缺省 `true`:追加到 `<项目根>/.kimi-code/local.toml` 的 `workspace.additional_dir`;为 `false` 时仅加入内存中的临时集合(同一工作区所有会话共享),不写盘 |

成功时 `data` 为 `{ project_root, config_path, additional_dirs, persisted }`,其中 `additional_dirs` 是全部附加目录(含既有目录),`persisted` 表示本次是否写盘。

- `40001`:校验失败(`details` 逐字段说明),或项目本地配置损坏等引擎校验错误
- `40409`:`path` 不存在或不是目录
- `40410`:工作区不存在

### 文件系统

会话内文件操作走 `POST /api/v1/sessions/{session_id}/fs:{action}`,请求体为 JSON;动作包括 `list` / `read` / `list_many` / `stat` / `stat_many` / `mkdir` / `search` / `grep` / `git_status` / `diff` / `open` / `open-in` / `reveal`。每个动作的请求体还接受可选的 `runtime_id`(string,默认 `local`),用于选择执行操作的运行时;`open`、`open-in` 与 `reveal` 仅在 `local` 运行时上可用。另有:
Expand Down
14 changes: 14 additions & 0 deletions packages/kap-server/src/protocol/rest-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,17 @@ export const workspaceTrustResponseSchema = z.object({
trusted: z.boolean(),
});
export type WorkspaceTrustResponse = z.infer<typeof workspaceTrustResponseSchema>;

export const addDirRequestSchema = z.object({
path: z.string().min(1),
persist: z.boolean().optional(),
});
export type AddDirRequest = z.infer<typeof addDirRequestSchema>;

export const addDirResponseSchema = z.object({
project_root: z.string(),
config_path: z.string(),
additional_dirs: z.array(z.string()),
persisted: z.boolean(),
});
export type AddDirResponse = z.infer<typeof addDirResponseSchema>;
83 changes: 82 additions & 1 deletion packages/kap-server/src/routes/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
IBootstrapService,
IHostFileSystem,
IWorkspaceInstanceManager,
IWorkspaceService,
Expand All @@ -7,7 +8,7 @@ import {
type Scope,
type Workspace,
} from '@moonshot-ai/agent-core-v2';
import { isAbsolute } from 'node:path';
import { isAbsolute, join, normalize, resolve } from 'node:path';

import { z } from 'zod';

Expand All @@ -16,6 +17,8 @@ import { requestLog } from '../lib/requestLog';
import { defineRoute } from '../middleware/defineRoute';
import { ErrorCode } from '../protocol/error-codes';
import {
addDirRequestSchema,
addDirResponseSchema,
createWorkspaceRequestSchema,
createWorkspaceResponseSchema,
deleteWorkspaceResponseSchema,
Expand Down Expand Up @@ -269,6 +272,84 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope):
untrustRoute.options,
untrustRoute.handler as Parameters<WorkspaceRouteHost['post']>[2],
);

const addDirRoute = defineRoute(
{
method: 'POST',
path: '/workspaces/{workspace_id}/add-dir',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required user-facing changeset

This commit adds and publicly documents a user-perceivable REST capability shipped with the CLI, but it contains no .changeset entry, so the release process will not record the feature in the CLI changelog or version bump. Add a confirmed changeset for @moonshot-ai/kimi-code alongside the endpoint.

AGENTS.md reference: AGENTS.md:L85-L85

Useful? React with 👍 / 👎.

params: workspaceIdParamSchema,
body: addDirRequestSchema,
success: { data: addDirResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.FS_PATH_NOT_FOUND]: {},
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
},
description: 'Add an additional directory to the workspace',
tags: ['workspaces'],
},
async (req, reply) => {
const { workspace_id } = req.params;
const ws = await core.accessor.get(IWorkspaceService).get(workspace_id);
if (ws === undefined) {
reply.send(
errEnvelope(ErrorCode.WORKSPACE_NOT_FOUND, `workspace ${workspace_id} does not exist`, req.id),
);
return;
}
const resolved = resolveAdditionalDirPath(core, ws.root, req.body.path);
const hostFs = core.accessor.get(IHostFileSystem);
try {
const stat = await hostFs.stat(resolved);
if (!stat.isDirectory) {
reply.send(
errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} is not a directory`, req.id),
);
return;
}
} catch {
reply.send(
errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `path ${req.body.path} does not exist`, req.id),
);
return;
}
const workspace = await core
.accessor.get(IWorkspaceInstanceManager)
.getOrCreate({ workspaceId: workspace_id, root: ws.root });
Comment on lines +316 to +318

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent add-dir from recreating a deleted workspace

If a concurrent DELETE /workspaces/{workspace_id} completes after the registry lookup but while this handler awaits the directory stat, this getOrCreate({ workspaceId, root }) call sees the missing id and falls back to createOrTouch(root), re-registering the workspace even though the delete request succeeded. Resolve the instance without the root fallback and map a concurrent not-found result so add-dir cannot undo deletion.

Useful? React with 👍 / 👎.

const result = await workspace.program.dirs.addDir({
path: req.body.path,
persist: req.body.persist,
});
reply.send(
okEnvelope(
{
project_root: result.projectRoot,
config_path: result.configPath,
additional_dirs: [...result.additionalDirs],
persisted: result.persisted,
},
req.id,
),
);
},
);
app.post(
addDirRoute.path,
addDirRoute.options,
addDirRoute.handler as Parameters<WorkspaceRouteHost['post']>[2],
);
}

function resolveAdditionalDirPath(core: Scope, root: string, input: string): string {
const trimmed = input.trim();
const osHomeDir = core.accessor.get(IBootstrapService).osHomeDir;
const expanded =
trimmed === '~'
? osHomeDir
: trimmed.startsWith('~/')
? join(osHomeDir, trimmed.slice(2))
: trimmed;
return isAbsolute(expanded) ? normalize(expanded) : resolve(root, expanded);
}

type TrustReply = { send(payload: unknown): unknown };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e
"POST",
"/api/v1/workspaces",
],
[
"POST",
"/api/v1/workspaces/{workspace_id}/add-dir",
],
[
"POST",
"/api/v1/workspaces/{workspace_id}/trust",
Expand Down
85 changes: 84 additions & 1 deletion packages/kap-server/test/workspaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -31,6 +31,13 @@ interface ListWire {
items: WorkspaceWire[];
}

interface AddDirWire {
project_root: string;
config_path: string;
additional_dirs: string[];
persisted: boolean;
}

describe('server-v2 /api/v1/workspaces', () => {
let server: RunningServer | undefined;
let home: string | undefined;
Expand Down Expand Up @@ -244,4 +251,80 @@ describe('server-v2 /api/v1/workspaces', () => {
expect([typedId, lowerId]).toContain(body.data.items[0]?.id);
expect(body.data.items[0]?.session_count).toBe(2);
});

it('adds an additional directory and persists it by default', async () => {
const root = home as string;
const extra = join(root, 'extra');
await mkdir(extra);
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;

const { status, body } = await postJson<AddDirWire>(`/api/v1/workspaces/${id}/add-dir`, {
path: extra,
});
expect(status).toBe(200);
expect(body.code).toBe(0);
expect(body.data.persisted).toBe(true);
expect(body.data.additional_dirs).toContain(extra);
expect(body.data.project_root).toBe(root);
expect(body.data.config_path).toBe(join(root, '.kimi-code', 'local.toml'));
const toml = await readFile(body.data.config_path, 'utf8');
expect(toml).toContain('additional_dir');
expect(toml).toContain(extra);
});

it('adds a relative directory without persisting when persist is false', async () => {
const root = home as string;
const extra = join(root, 'extra-rel');
await mkdir(extra);
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;

const { body } = await postJson<AddDirWire>(`/api/v1/workspaces/${id}/add-dir`, {
path: 'extra-rel',
persist: false,
});
expect(body.code).toBe(0);
expect(body.data.persisted).toBe(false);
expect(body.data.additional_dirs).toContain(extra);
await expect(readFile(body.data.config_path, 'utf8')).rejects.toThrow();
});

it('returns 40410 when adding a directory to an unknown workspace', async () => {
const { body } = await postJson<null>('/api/v1/workspaces/wd_missing_000000000000/add-dir', {
path: '/tmp',
});
expect(body.code).toBe(40410);
});

it('returns 40409 when the added path does not exist', async () => {
const root = home as string;
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;

const { body } = await postJson<null>(`/api/v1/workspaces/${id}/add-dir`, {
path: join(root, 'does-not-exist'),
});
expect(body.code).toBe(40409);
});

it('returns 40409 when the added path is a file', async () => {
const root = home as string;
const file = join(root, 'a-file.txt');
await writeFile(file, 'x', 'utf8');
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;

const { body } = await postJson<null>(`/api/v1/workspaces/${id}/add-dir`, { path: file });
expect(body.code).toBe(40409);
});

it('returns 40001 when path is missing', async () => {
const root = home as string;
const created = await postJson<WorkspaceWire>('/api/v1/workspaces', { root });
const id = created.body.data.id;

const { body } = await postJson<null>(`/api/v1/workspaces/${id}/add-dir`, {});
expect(body.code).toBe(40001);
});
});
Loading