Make English the repository working language (#5)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-20 17:45:02 +08:00
committed by GitHub
parent e80cde0ea6
commit 0eaaa54e25
136 changed files with 1515 additions and 1352 deletions
+4 -4
View File
@@ -254,7 +254,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
});
return handleError(err, c);
});
app.notFound((c) => c.json(errorBody("not_found", "接口不存在。"), 404));
app.notFound((c) => c.json(errorBody("not_found", "Endpoint does not exist."), 404));
// Request logging: a minimal one-liner (method path status ms).
app.use("*", async (c, next) => {
@@ -268,7 +268,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.use("/api/*", async (c, next) => {
const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_BODY_BYTES) {
throw new HttpError(413, "payload_too_large", "请求体超过 20MB 上限。");
throw new HttpError(413, "payload_too_large", "Request body exceeds the 20MB limit.");
}
await next();
});
@@ -362,7 +362,7 @@ function registerStaticRoutes(app: Hono<AppEnv>, webDist: string): void {
app.get("*", async (c) => {
const reqPath = decodeURIComponent(c.req.path);
if (reqPath.startsWith("/api/")) {
return c.json(errorBody("not_found", "接口不存在。"), 404);
return c.json(errorBody("not_found", "Endpoint does not exist."), 404);
}
const rel = reqPath.replace(/^\/+/, "");
const resolved = path.resolve(webDist, rel === "" ? "index.html" : rel);
@@ -384,7 +384,7 @@ function registerStaticRoutes(app: Hono<AppEnv>, webDist: string): void {
try {
content = await fsp.readFile(file);
} catch {
return c.json(errorBody("not_found", "资源不存在。"), 404);
return c.json(errorBody("not_found", "Resource does not exist."), 404);
}
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
return new Response(new Uint8Array(content), {
+6 -2
View File
@@ -33,7 +33,7 @@ export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null;
if (!user) {
throw new HttpError(401, "unauthorized", "未登录或登录已过期。");
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
}
c.set("user", user);
await next();
@@ -51,7 +51,11 @@ export const jsonOnlyWrites: MiddlewareHandler = async (c, next) => {
if (WRITE_METHODS.has(c.req.method)) {
const contentType = c.req.header("content-type");
if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
throw new HttpError(415, "unsupported_media_type", "写请求仅接受 application/json。");
throw new HttpError(
415,
"unsupported_media_type",
"Write requests only accept application/json.",
);
}
}
await next();
+3 -3
View File
@@ -81,7 +81,7 @@ export class AuthService {
const row = this.deps.users.findById(userId);
const ok = row !== null && (await verifyPassword(password, row.passwordHash));
if (!row || !ok) {
throw new HttpError(401, "invalid_credentials", "用户名或密码错误。");
throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
@@ -91,10 +91,10 @@ export class AuthService {
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const row = this.deps.users.findById(userId);
if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {
throw new HttpError(400, "password_mismatch", "当前密码不正确。");
throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): Serve
// port; this matches the CLI's resolvePort convention.
const port = Number(env.PORT || 7364);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`非法端口配置 PORT=${env.PORT}`);
throw new Error(`Invalid port configuration PORT=${env.PORT}`);
}
return {
root,
+1 -1
View File
@@ -114,7 +114,7 @@ export class SchedulesRepo {
.run(args.defHash, args.projectId, args.agentId, args.name);
}
const row = this.find(args.projectId, args.agentId, args.name);
if (!row) throw new Error("schedule_state 登记后读取失败");
if (!row) throw new Error("Failed to read back schedule_state after registration");
return { row, fresh };
}
+38 -38
View File
@@ -9,30 +9,30 @@
export const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- 语义 id 即登录名:^[a-z][a-z0-9_-]{1,31}$
user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$
password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)
is_admin INTEGER NOT NULL DEFAULT 0, -- 内置 admin(启动时种子)为 1
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=初始密码(种子/管理员设置);本人改密后清 0
is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)
password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex;cookie 只存原始 token
token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL -- 7 天滑动续期(剩余 <6 天则续满)
expires_at TEXT NOT NULL -- 7-day sliding renewal (topped up when <6 days remain)
);
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY, -- 目录名即 id;显示名在 project_config.toml
project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
owner_user_id TEXT NOT NULL REFERENCES users(user_id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_members ( -- 仅 member 授权关系;owner 不入此表
CREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE TABLE IF NOT EXISTS agents ( -- 索引;name/description 在 system_config.yaml
CREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
created_at TEXT NOT NULL,
@@ -42,63 +42,63 @@ CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
provider TEXT NOT NULL, -- 会话模型的厂商分组(与 model_id 成对构成模型引用)
model_id TEXT NOT NULL, -- 上游模型 id(原样发给 AgentHub;禁止 <provider>/<id> 拼接)
provider TEXT NOT NULL, -- provider group of the session model (paired with model_id as a model reference)
model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub as-is; never concatenate <provider>/<id>)
workspace TEXT NOT NULL,
approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask
title TEXT, -- 首次对话后由模型自动生成;NULL=未生成(前端显示「新对话」)
archived_at TEXT, -- 归档时刻;NULL=未归档(默认展示;归档后收进「已归档」)
source TEXT, -- 会话来源:NULL=用户创建 | schedule(定时任务)| subagent(子会话)
title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows "New chat")
archived_at TEXT, -- archive time; NULL=not archived (shown by default; archived ones move under "Archived")
source TEXT, -- session origin: NULL=user-created | schedule (scheduled task) | subagent (child session)
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS usage_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(聚合键,与 Trace 日期目录同口径)
date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL, -- 顶层 Session(子会话消耗计入所属主 Session)
origin_session_id TEXT, -- 直接来源子 Session(origin 链末项);NULL=主会话
provider TEXT NOT NULL, -- 厂商分组:与 model_id 成对构成归因键,聚合一律 GROUP BY provider, model_id
model_id TEXT NOT NULL, -- 上游模型 id(与 provider 成对;同名 model_id 跨厂商分开聚合)
session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)
origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session
provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id
model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)
cache_read INTEGER NOT NULL,
cache_write INTEGER NOT NULL,
output INTEGER NOT NULL,
total INTEGER NOT NULL, -- 取 token_usage.request(每 Request 一条)
status TEXT NOT NULL DEFAULT 'completed' -- 请求结局:completed=成功(含 token);其余=失败(0 token,供成功率)
); -- 成本不落库:查询时按当前 pricing 实时折算
total INTEGER NOT NULL, -- from token_usage.request (one row per Request)
status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)
); -- cost is not stored: computed at query time from current pricing
CREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_records(session_id);
CREATE TABLE IF NOT EXISTS error_records ( -- 服务端异常捕获(统计中心「异常」)
CREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's "Errors" tab)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(与 usage_records 同口径)
project_id TEXT, -- 可空:登录/注册、进程级异常没有 Project 上下文
date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)
project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context
agent_id TEXT,
session_id TEXT,
source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | schedule
kind TEXT NOT NULL, -- expected(HttpError,业务 4xx)| unexpected(500/运行时)
kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
status INTEGER, -- HTTP 状态码;非 HTTP 来源为 NULL
message TEXT NOT NULL -- 截断 500 字符(不存堆栈:堆栈只进日志)
status INTEGER, -- HTTP status code; NULL for non-HTTP sources
message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)
);
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
CREATE TABLE IF NOT EXISTS schedule_state ( -- 定时任务运行状态(文件是声明式意图,系统不写回)
CREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
name TEXT NOT NULL, -- 文件名(去 .toml)即标识
creator_user_id TEXT, -- 创建者(API 创建时记;手编文件对账登记回退 Project owner)
start_at_ms INTEGER NOT NULL, -- 定义身份:start_at 变更视为新任务实例,重置触发状态
def_hash TEXT NOT NULL, -- 文件内容指纹:变更即清除失效标记(文件修改后重新生效)
last_slot_ms INTEGER, -- 已消化的最近应触发时刻(触发或跳过都推进;重启不重复触发)
last_fired_at TEXT, -- 最近实际发送时刻(展示用)
fired_once INTEGER NOT NULL DEFAULT 0, -- 一次性任务已触发
missed INTEGER NOT NULL DEFAULT 0, -- 一次性任务已错过(启动/登记对账标记,错过不补)
invalid_reason TEXT, -- 失效原因(如绑定 Session 已删除);NULL 即正常
name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier
creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)
start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state
def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)
last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)
last_fired_at TEXT, -- last actual fire time (for display)
fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired
missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)
invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy
PRIMARY KEY (project_id, agent_id, name)
);
CREATE TABLE IF NOT EXISTS ui_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} 自由 JSON
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
);
`;
+4 -4
View File
@@ -1,6 +1,6 @@
/**
* Unified HTTP error: `{error: {code, message}}` response body,
* with message in Chinese.
* with message in English.
*
* Routes and services express business errors via throw HttpError; app-level onError
* uniformly converges these into a JSON response, with unknown errors converged to 500
@@ -40,7 +40,7 @@ export function modelCredentialMissing(modelId: string): HttpError {
return new HttpError(
400,
"model_credential_missing",
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置。`,
`Model ${modelId} has no API key yet. Configure it on the Models page first.`,
);
}
@@ -50,6 +50,6 @@ export function handleError(err: Error, c: Context): Response {
return c.json(errorBody(err.code, err.message), err.status as 400);
}
// Unknown error: print the stack for diagnosis, but never expose details externally.
console.error(`[server] 未处理异常: ${err.stack ?? err.message}`);
return c.json(errorBody("internal", "服务器内部错误。"), 500);
console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
return c.json(errorBody("internal", "Internal server error."), 500);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "该操作仅管理员可执行。");
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
}
await next();
});
@@ -33,7 +33,7 @@ export function agentConfigRoutes(deps: AppDeps): Hono<AppEnv> {
if (agentsMd !== undefined) req.agentsMd = agentsMd;
if (body.config !== undefined) {
if (body.config === null || typeof body.config !== "object" || Array.isArray(body.config)) {
throw badRequest("config 必须是对象。");
throw badRequest("config must be an object.");
}
req.config = body.config as AgentConfigUpdateRequest["config"];
}
@@ -42,10 +42,11 @@ export function agentTransferRoutes(deps: AppDeps): Hono<AppEnv> {
try {
archive = Buffer.from(dataBase64, "base64");
} catch {
throw badRequest("dataBase64 不是合法的 base64。");
throw badRequest("dataBase64 is not valid base64.");
}
if (archive.byteLength === 0) throw badRequest("导入包为空。");
if (archive.byteLength > MAX_ARCHIVE_BYTES) throw badRequest("导入包超过 14MB 上限。");
if (archive.byteLength === 0) throw badRequest("Import package is empty.");
if (archive.byteLength > MAX_ARCHIVE_BYTES)
throw badRequest("Import package exceeds the 14MB limit.");
const { version } = await deps.snapshots.importArchive(projectId, agentId, archive, confirm);
const res: AgentImportResponse = { version };
return c.json(res);
+7 -3
View File
@@ -31,18 +31,22 @@ export function dirsRoutes(deps: AppDeps): Hono<AppEnv> {
const raw = c.req.query("path");
const target = raw && raw.trim() ? raw.trim() : os.homedir();
if (!path.isAbsolute(target)) {
throw new HttpError(400, "dir_not_absolute", "目录必须是绝对路径。");
throw new HttpError(400, "dir_not_absolute", "Directory must be an absolute path.");
}
let real: string;
try {
real = await fs.realpath(target);
} catch {
throw new HttpError(404, "dir_not_found", `目录不存在或不可访问:${target}。`);
throw new HttpError(
404,
"dir_not_found",
`Directory does not exist or is inaccessible: ${target}.`,
);
}
const stat = await fs.stat(real);
if (!stat.isDirectory()) {
throw new HttpError(400, "not_a_dir", "不是目录。");
throw new HttpError(400, "not_a_dir", "Not a directory.");
}
let dirents: import("node:fs").Dirent[] = [];
+16 -16
View File
@@ -18,7 +18,7 @@ import type { AppDeps } from "../../app.js";
/** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */
function parseRef(value: unknown, label: string): ModelRefDto {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw badRequest(`${label} 必须是 { provider, modelId } 对象。`);
throw badRequest(`${label} must be a { provider, modelId } object.`);
}
const r = value as Record<string, unknown>;
return {
@@ -29,10 +29,10 @@ function parseRef(value: unknown, label: string): ModelRefDto {
/** Validate the PUT request body and shape it into a ModelsUpdateRequest (rejects any shape errors). */
function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
if (!Array.isArray(body.models)) throw badRequest("models 必须是数组。");
if (!Array.isArray(body.models)) throw badRequest("models must be an array.");
const models: ModelUpdateEntry[] = body.models.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`models[${i}] 必须是对象。`);
throw badRequest(`models[${i}] must be an object.`);
}
const m = item as Record<string, unknown>;
const entry: ModelUpdateEntry = {
@@ -49,7 +49,7 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
};
if (m.displayName !== undefined) {
if (typeof m.displayName !== "string" || m.displayName.length > 100) {
throw badRequest(`models[${i}].displayName 必须是长度不超过 100 的字符串。`);
throw badRequest(`models[${i}].displayName must be a string of at most 100 characters.`);
}
if (m.displayName) entry.displayName = m.displayName;
}
@@ -59,32 +59,32 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
}
if (m.contextWindow !== undefined) {
if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
throw badRequest(`models[${i}].contextWindow 必须是正数。`);
throw badRequest(`models[${i}].contextWindow must be a positive number.`);
}
entry.contextWindow = m.contextWindow;
}
if (m.clientType !== undefined) {
if (typeof m.clientType !== "string" || m.clientType.length > 64) {
throw badRequest(`models[${i}].clientType 必须是长度不超过 64 的字符串。`);
throw badRequest(`models[${i}].clientType must be a string of at most 64 characters.`);
}
// An empty string is treated as "unspecified", leaving AgentHub to infer it from modelId.
if (m.clientType) entry.clientType = m.clientType;
}
if (m.vision !== undefined) {
if (typeof m.vision !== "boolean") {
throw badRequest(`models[${i}].vision 必须是布尔值。`);
throw badRequest(`models[${i}].vision must be a boolean.`);
}
entry.vision = m.vision;
}
if (m.pricing !== undefined) {
const p = m.pricing as Record<string, unknown>;
if (p === null || typeof p !== "object" || Array.isArray(p)) {
throw badRequest(`models[${i}].pricing 必须是对象。`);
throw badRequest(`models[${i}].pricing must be an object.`);
}
for (const key of ["cacheRead", "cacheWrite", "output"] as const) {
const v = p[key];
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
throw badRequest(`models[${i}].pricing.${key} 必须是非负数字。`);
throw badRequest(`models[${i}].pricing.${key} must be a non-negative number.`);
}
}
entry.pricing = {
@@ -95,19 +95,19 @@ function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
}
if (m.apiKey !== undefined) {
if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
throw badRequest(`models[${i}].apiKey 必须是非空字符串。`);
throw badRequest(`models[${i}].apiKey must be a non-empty string.`);
}
entry.apiKey = m.apiKey;
}
if (m.clearApiKey !== undefined) {
if (typeof m.clearApiKey !== "boolean") {
throw badRequest(`models[${i}].clearApiKey 必须是布尔值。`);
throw badRequest(`models[${i}].clearApiKey must be a boolean.`);
}
entry.clearApiKey = m.clearApiKey;
}
if (m.baseUrl !== undefined) {
if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
throw badRequest(`models[${i}].baseUrl 必须是字符串或 null。`);
throw badRequest(`models[${i}].baseUrl must be a string or null.`);
}
entry.baseUrl = m.baseUrl as string | null;
}
@@ -153,22 +153,22 @@ export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
};
if (body.apiKey !== undefined) {
if (typeof body.apiKey !== "string") throw badRequest("apiKey 必须是字符串。");
if (typeof body.apiKey !== "string") throw badRequest("apiKey must be a string.");
if (body.apiKey) req.apiKey = body.apiKey;
}
if (body.clearApiKey !== undefined) {
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey 必须是布尔值。");
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
req.clearApiKey = body.clearApiKey;
}
// null = explicit clear (test against the draft, don't fall back to the stored value); empty string is treated as null.
if (body.baseUrl !== undefined) {
if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
throw badRequest("baseUrl 必须是字符串或 null。");
throw badRequest("baseUrl must be a string or null.");
}
req.baseUrl = body.baseUrl ? body.baseUrl : null;
}
if (body.clientType !== undefined) {
if (typeof body.clientType !== "string") throw badRequest("clientType 必须是字符串。");
if (typeof body.clientType !== "string") throw badRequest("clientType must be a string.");
if (body.clientType) req.clientType = body.clientType;
}
return c.json(await deps.projectConfigService.testModel(projectId, req));
+10 -9
View File
@@ -48,7 +48,7 @@ function parseUpsertBody(body: Record<string, unknown>): {
modelId?: string;
provider?: string;
} {
if (typeof body.enabled !== "boolean") throw badRequest("enabled 必须是布尔值。");
if (typeof body.enabled !== "boolean") throw badRequest("enabled must be a boolean.");
const prompt = requireString(body, "prompt", { minLen: 1, maxLen: 100_000 });
const startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 });
const period = optionalString(body, "period", { minLen: 1, maxLen: 20 });
@@ -128,7 +128,7 @@ function toItem(
/** Schedule name in the path: same character rules as directories/files, validated before any path construction. */
function requireScheduleName(raw: string | undefined): string {
if (!raw || !isValidId(raw)) throw badRequest("定时任务名非法。");
if (!raw || !isValidId(raw)) throw badRequest("Invalid schedule name.");
return raw;
}
@@ -157,7 +157,7 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
const body = await readJson(c);
const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 }));
if (await readScheduleFile(deps.config.root, projectId, agentId, name)) {
throw new HttpError(409, "schedule_exists", `定时任务已存在:${name}`);
throw new HttpError(409, "schedule_exists", `Schedule already exists: ${name}`);
}
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
return c.json(await readItem(deps, projectId, agentId, name), 201);
@@ -178,7 +178,7 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name"));
if (!(await readScheduleFile(deps.config.root, projectId, agentId, name))) {
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
}
const body = await readJson(c);
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
@@ -191,7 +191,8 @@ export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name"));
const removed = await deleteScheduleFile(deps.config.root, projectId, agentId, name);
if (!removed) throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
if (!removed)
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
deps.scheduler.dropEntry(projectId, agentId, name);
return c.body(null, 204);
});
@@ -211,10 +212,10 @@ async function upsert(
const fields = parseUpsertBody(body);
const raw = serializeSchedule(fields);
const parsed = parseScheduleFile(name, raw);
if (!parsed.ok) throw badRequest(`定时任务配置非法:${parsed.error}`);
if (!parsed.ok) throw badRequest(`Invalid schedule configuration: ${parsed.error}`);
// At save time, verify the model reference resolves (resolveModelRef semantics; same rules as reconciliation) so we never persist a broken file.
const refError = await validateScheduleModelRef(deps.config.root, projectId, parsed.def);
if (refError !== null) throw badRequest(`定时任务配置非法:${refError}`);
if (refError !== null) throw badRequest(`Invalid schedule configuration: ${refError}`);
await writeScheduleFile(deps.config.root, projectId, agentId, name, raw);
// Creator attribution: the API writer is the creator (falls back to the Project owner only for hand-edited files).
deps.schedulesRepo.registerOrSync({
@@ -238,6 +239,6 @@ async function readItem(
const entry = entries.find((e) => e.def.name === name);
if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now());
const bad = invalid.find((i) => i.name === name);
if (bad) throw badRequest(`定时任务文件非法:${bad.error}`);
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
if (bad) throw badRequest(`Invalid schedule file: ${bad.error}`);
throw new HttpError(404, "schedule_not_found", `Schedule does not exist: ${name}`);
}
+39 -17
View File
@@ -59,16 +59,16 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
const input = body.input;
if (!Array.isArray(input) || input.length === 0) {
throw badRequest("input 必须是至少包含一项的数组。");
throw badRequest("input must be an array with at least one item.");
}
return input.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`input[${i}] 必须是对象。`);
throw badRequest(`input[${i}] must be an object.`);
}
const part = item as Record<string, unknown>;
if (part.type === "text") {
if (typeof part.text !== "string" || part.text.length === 0) {
throw badRequest(`input[${i}].text 必须是非空字符串。`);
throw badRequest(`input[${i}].text must be a non-empty string.`);
}
return userText(part.text);
}
@@ -78,11 +78,11 @@ function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
typeof url !== "string" ||
!(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))
) {
throw badRequest(`input[${i}].imageUrl 仅支持 data: 或 http(s) URL。`);
throw badRequest(`input[${i}].imageUrl only supports data: or http(s) URLs.`);
}
return imageUrlMessage(url);
}
throw badRequest(`input[${i}].type 必须是 text / image_url 之一。`);
throw badRequest(`input[${i}].type must be one of text / image_url.`);
});
}
@@ -110,7 +110,9 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const provider = optionalString(body, "provider", { minLen: 1, label: "provider" });
// Model reference is submitted as a pair: provider can't appear without modelId (core does the same validation; this catches it early).
if (provider !== undefined && modelId === undefined) {
throw badRequest("指定了 provider 却未指定 modelId:模型引用须成对给出。");
throw badRequest(
"provider is specified but modelId is not: a model reference must be given as a pair.",
);
}
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" });
@@ -141,12 +143,20 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const sessionId = c.req.param("sessionId");
const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null;
if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
}
try {
deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
} catch {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
}
return row;
};
@@ -167,15 +177,23 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
let title: string | undefined;
if (titleRaw !== undefined) {
if (typeof titleRaw !== "string") {
throw new HttpError(400, "invalid_title", "title 必须是字符串。");
throw new HttpError(400, "invalid_title", "title must be a string.");
}
title = titleRaw.trim();
if (!title || title.length > SESSION_TITLE_MAX) {
throw new HttpError(400, "invalid_title", `title 需为 1–${SESSION_TITLE_MAX} 个字符。`);
throw new HttpError(
400,
"invalid_title",
`title must be 1–${SESSION_TITLE_MAX} characters.`,
);
}
}
if (approvalMode === undefined && archived === undefined && title === undefined) {
throw new HttpError(400, "no_update", "缺少可更新字段(approvalMode / archived / title)。");
throw new HttpError(
400,
"no_update",
"No updatable field provided (approvalMode / archived / title).",
);
}
let updated: SessionRow = { ...row };
if (title !== undefined) {
@@ -239,7 +257,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const row = resolveSession(c);
const fileName = c.req.param("fileName") ?? "";
if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
throw new HttpError(404, "file_not_found", "文件不存在。");
throw new HttpError(404, "file_not_found", "File does not exist.");
}
const filePath = path.join(
scratchpadDir(deps.config.root, row.projectId, row.agentId),
@@ -250,7 +268,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
try {
bytes = await fs.readFile(filePath);
} catch {
throw new HttpError(404, "file_not_found", "文件不存在。");
throw new HttpError(404, "file_not_found", "File does not exist.");
}
const MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
@@ -309,7 +327,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const decision = requireEnum(body, "decision", ["allow", "deny"] as const);
const ok = deps.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision);
if (!ok) {
throw new HttpError(404, "approval_not_found", "该审批不存在或已被决定。");
throw new HttpError(
404,
"approval_not_found",
"Approval does not exist or has already been decided.",
);
}
return c.body(null, 204);
});
@@ -374,7 +396,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
!paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN)
) {
throw badRequest(
`paths 必须是字符串数组(≤${STAT_MAX_PATHS} 项,每项 ≤${STAT_MAX_PATH_LEN} 字符)。`,
`paths must be an array of strings (≤${STAT_MAX_PATHS} items, each ≤${STAT_MAX_PATH_LEN} characters).`,
);
}
const existing = await deps.workspaceFiles.statExisting(row.workspace, paths as string[]);
@@ -386,11 +408,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const rel = c.req.query("path") ?? "";
const body = await readJson(c);
if (typeof body.dataBase64 !== "string") {
throw badRequest("dataBase64 必须是 base64 字符串。");
throw badRequest("dataBase64 must be a base64 string.");
}
const data = Buffer.from(body.dataBase64, "base64");
if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。");
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
}
await deps.workspaceFiles.write(row.workspace, rel, data);
return c.body(null, 204);
+4 -4
View File
@@ -64,11 +64,11 @@ function libraryResponse(): SkillLibraryResponse {
/** Validate the POST request body: names must be a non-empty array of strings. */
function parseInstallNames(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.names) || body.names.length === 0) {
throw badRequest("names 必须是非空数组。");
throw badRequest("names must be a non-empty array.");
}
return body.names.map((v, i) => {
if (typeof v !== "string" || v.length === 0) {
throw badRequest(`names[${i}] 必须是非空字符串。`);
throw badRequest(`names[${i}] must be a non-empty string.`);
}
return v;
});
@@ -110,7 +110,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
// Verify all names up front before writing anything: if any name isn't in the library, reject the whole request rather than leaving a half-installed state.
const skills: LibrarySkill[] = names.map((name) => {
const skill = librarySkill(name);
if (!skill) throw new HttpError(404, "unknown_skill", `Skill 库中不存在:${name}`);
if (!skill) throw new HttpError(404, "unknown_skill", `Skill is not in the library: ${name}`);
return skill;
});
for (const skill of skills) {
@@ -129,7 +129,7 @@ export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
try {
await fs.access(file);
} catch {
throw new HttpError(404, "not_found", `Skill 未安装:${name}`);
throw new HttpError(404, "not_found", `Skill is not installed: ${name}`);
}
await removeSkill(deps.config.root, projectId, agentId, name);
return c.body(null, 204);
+1 -1
View File
@@ -20,7 +20,7 @@ export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const groupByRaw = c.req.query("groupBy") ?? "date";
if (!(GROUP_BYS as readonly string[]).includes(groupByRaw)) {
throw badRequest(`groupBy 必须是 ${GROUP_BYS.join(" / ")} 之一。`);
throw badRequest(`groupBy must be one of ${GROUP_BYS.join(" / ")}.`);
}
const from = optionalDateParam(c.req.query("from"), "from");
const to = optionalDateParam(c.req.query("to"), "to");
+3 -3
View File
@@ -11,10 +11,10 @@ import type { AppDeps } from "../../app.js";
/** Validate the PUT request body and shape it into a VaultUpdateRequest (semantic checks like key-name rules live in the service layer). */
function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest {
if (!Array.isArray(body.entries)) throw badRequest("entries 必须是数组。");
if (!Array.isArray(body.entries)) throw badRequest("entries must be an array.");
const entries: VaultEntryUpdate[] = body.entries.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`entries[${i}] 必须是对象。`);
throw badRequest(`entries[${i}] must be an object.`);
}
const e = item as Record<string, unknown>;
const entry: VaultEntryUpdate = {
@@ -22,7 +22,7 @@ function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest {
};
if (e.value !== undefined) {
if (typeof e.value !== "string" || e.value.length === 0) {
throw badRequest(`entries[${i}].value 必须是非空字符串。`);
throw badRequest(`entries[${i}].value must be a non-empty string.`);
}
entry.value = e.value;
}
+18 -17
View File
@@ -19,7 +19,7 @@ export function badRequest(message: string): HttpError {
export function pathParam(c: Context, name: string): string {
const v = c.req.param(name);
if (v === undefined || v === "") {
throw new HttpError(404, "not_found", "路径参数缺失。");
throw new HttpError(404, "not_found", "Missing path parameter.");
}
return v;
}
@@ -35,7 +35,7 @@ export function pathParam(c: Context, name: string): string {
export function requireValidId(c: Context, name: string): string {
const v = pathParam(c, name);
if (!isValidId(v)) {
throw new HttpError(404, "not_found", "资源不存在或无权访问。");
throw new HttpError(404, "not_found", "Resource does not exist or you do not have access.");
}
return v;
}
@@ -43,7 +43,7 @@ export function requireValidId(c: Context, name: string): string {
/** Parse a positive-integer path parameter (e.g. Trace file index). */
export function positiveIntParam(c: Context, name: string): number {
const v = Number.parseInt(pathParam(c, name), 10);
if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} 必须是正整数。`);
if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} must be a positive integer.`);
return v;
}
@@ -51,9 +51,10 @@ export function positiveIntParam(c: Context, name: string): number {
export function paginationQuery(c: Context): { offset: number; limit: number } {
const offset = Number.parseInt(c.req.query("offset") ?? "0", 10);
const limit = Number.parseInt(c.req.query("limit") ?? "200", 10);
if (!Number.isInteger(offset) || offset < 0) throw badRequest("offset 必须是非负整数。");
if (!Number.isInteger(offset) || offset < 0)
throw badRequest("offset must be a non-negative integer.");
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
throw badRequest("limit 必须是 1~1000 的整数。");
throw badRequest("limit must be an integer between 1 and 1000.");
}
return { offset, limit };
}
@@ -64,10 +65,10 @@ export async function readJson(c: Context): Promise<Record<string, unknown>> {
try {
body = await c.req.json();
} catch {
throw badRequest("请求体必须是合法 JSON。");
throw badRequest("Request body must be valid JSON.");
}
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw badRequest("请求体必须是 JSON 对象。");
throw badRequest("Request body must be a JSON object.");
}
return body as Record<string, unknown>;
}
@@ -87,15 +88,15 @@ export function requireString(
): string {
const v = obj[key];
const label = rule.label ?? key;
if (typeof v !== "string") throw badRequest(`${label} 必须是字符串。`);
if (typeof v !== "string") throw badRequest(`${label} must be a string.`);
if (rule.minLen !== undefined && v.length < rule.minLen) {
throw badRequest(`${label} 长度至少 ${rule.minLen} 个字符。`);
throw badRequest(`${label} must be at least ${rule.minLen} characters.`);
}
if (rule.maxLen !== undefined && v.length > rule.maxLen) {
throw badRequest(`${label} 长度不能超过 ${rule.maxLen} 个字符。`);
throw badRequest(`${label} must be at most ${rule.maxLen} characters.`);
}
if (rule.pattern !== undefined && !rule.pattern.test(v)) {
throw badRequest(`${label} 格式不合法。`);
throw badRequest(`${label} has an invalid format.`);
}
return v;
}
@@ -117,7 +118,7 @@ export function requireEnum<T extends string>(
): T {
const v = obj[key];
if (typeof v !== "string" || !(values as readonly string[]).includes(v)) {
throw badRequest(`${label} 必须是 ${values.join(" / ")} 之一。`);
throw badRequest(`${label} must be one of ${values.join(" / ")}.`);
}
return v as T;
}
@@ -150,12 +151,12 @@ export function optionalNumber(
const v = obj[key];
if (v === undefined) return undefined;
const label = rule.label ?? key;
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} 必须是数字。`);
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} 必须是整数。`);
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} must be a number.`);
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} must be an integer.`);
if (rule.positiveOrMinusOne && !(v > 0 || v === -1)) {
throw badRequest(`${label} 必须大于 0 或为 -1。`);
throw badRequest(`${label} must be greater than 0 or equal to -1.`);
}
if (rule.nonNegative && v < 0) throw badRequest(`${label} 不能为负数。`);
if (rule.nonNegative && v < 0) throw badRequest(`${label} must not be negative.`);
return v;
}
@@ -163,7 +164,7 @@ export function optionalNumber(
export function optionalDateParam(value: string | undefined, label: string): string | undefined {
if (value === undefined || value === "") return undefined;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw badRequest(`${label} 必须是 YYYY-MM-DD 格式。`);
throw badRequest(`${label} must be in YYYY-MM-DD format.`);
}
return value;
}
+5 -5
View File
@@ -27,8 +27,8 @@ await deps.authService.seedAdmin();
await deps.scheduler.start();
const server = serve({ fetch: app.fetch, hostname: config.host, port: config.port }, (info) => {
console.log(`penguin-server 已启动: http://${config.host}:${info.port}`);
console.log(`数据根目录: ${config.root}`);
console.log(`penguin-server started: http://${config.host}:${info.port}`);
console.log(`Data root: ${config.root}`);
console.log(`SQLite: ${config.dbPath}`);
});
@@ -36,7 +36,7 @@ let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`收到 ${signal},正在关停…`);
console.log(`Received ${signal}, shutting down…`);
deps.scheduler.stop();
await deps.manager.shutdown(5000);
deps.channels.dispose();
@@ -56,7 +56,7 @@ process.on("SIGTERM", () => void shutdown("SIGTERM"));
// reaches the process without passing through any catch — persist it first for a
// record, then handle each case according to its nature.
process.on("uncaughtException", (err) => {
console.error(`[server] 未捕获异常: ${err.stack ?? err.message}`);
console.error(`[server] Uncaught exception: ${err.stack ?? err.message}`);
deps.errors.record({ source: "process", err, code: "uncaught_exception" });
// From this point the process state can't be trusted (the error was never converged
// by any catch): don't swallow it — wrap up per existing shutdown semantics and exit
@@ -68,7 +68,7 @@ process.on("uncaughtException", (err) => {
});
process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
console.error(`[server] 未处理的 Promise 拒绝: ${err.stack ?? err.message}`);
console.error(`[server] Unhandled promise rejection: ${err.stack ?? err.message}`);
deps.errors.record({ source: "process", err, code: "unhandled_rejection" });
// Unlike uncaughtException, this **doesn't** exit: a rejected promise is a localized
// failure of some background task, and the process state isn't compromised; dragging
+22 -16
View File
@@ -82,30 +82,31 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
} catch (err) {
return {
ok: false,
error: `TOML 解析失败:${err instanceof Error ? err.message : String(err)}`,
error: `Failed to parse TOML: ${err instanceof Error ? err.message : String(err)}`,
};
}
if (parsed === null || typeof parsed !== "object")
return { ok: false, error: "内容不是 TOML 表" };
return { ok: false, error: "Content is not a TOML table" };
const t = parsed as Record<string, unknown>;
const prompt = t["prompt"];
if (typeof prompt !== "string" || prompt.trim() === "") {
return { ok: false, error: "缺少必填字段 prompt" };
return { ok: false, error: "Missing required field prompt" };
}
const enabled = t["enabled"] === undefined ? false : t["enabled"];
if (typeof enabled !== "boolean") return { ok: false, error: "enabled 必须是布尔值" };
if (typeof enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
const startAt = parseInstant(t["start_at"]);
if (startAt === null) return { ok: false, error: "start_at 缺失或不是合法的 ISO 8601 时刻" };
if (startAt === null)
return { ok: false, error: "start_at is missing or not a valid ISO 8601 instant" };
let period: string | undefined;
let periodMs: number | undefined;
if (t["period"] !== undefined) {
if (typeof t["period"] !== "string") return { ok: false, error: "period 必须是字符串" };
if (typeof t["period"] !== "string") return { ok: false, error: "period must be a string" };
const ms = parsePeriod(t["period"]);
if (ms === null) return { ok: false, error: "period 必须形如 30m / 12h / 7d" };
if (ms < MIN_PERIOD_MS) return { ok: false, error: "period 低于下限 5m" };
if (ms === null) return { ok: false, error: "period must look like 30m / 12h / 7d" };
if (ms < MIN_PERIOD_MS) return { ok: false, error: "period is below the 5m minimum" };
period = t["period"].trim();
periodMs = ms;
}
@@ -113,41 +114,46 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
let endAt: { ms: number; raw: string } | undefined;
if (t["end_at"] !== undefined) {
const parsedEnd = parseInstant(t["end_at"]);
if (parsedEnd === null) return { ok: false, error: "end_at 不是合法的 ISO 8601 时刻" };
if (parsedEnd.ms <= startAt.ms) return { ok: false, error: "end_at 必须晚于 start_at" };
if (parsedEnd === null) return { ok: false, error: "end_at is not a valid ISO 8601 instant" };
if (parsedEnd.ms <= startAt.ms)
return { ok: false, error: "end_at must be later than start_at" };
endAt = parsedEnd;
}
let sessionId: string | undefined;
if (t["session_id"] !== undefined) {
if (typeof t["session_id"] !== "string" || t["session_id"] === "") {
return { ok: false, error: "session_id 必须是非空字符串" };
return { ok: false, error: "session_id must be a non-empty string" };
}
sessionId = t["session_id"];
}
let workspace: string | undefined;
if (t["workspace"] !== undefined) {
if (typeof t["workspace"] !== "string" || t["workspace"] === "") {
return { ok: false, error: "workspace 必须是非空字符串" };
return { ok: false, error: "workspace must be a non-empty string" };
}
workspace = t["workspace"];
}
let modelId: string | undefined;
if (t["model_id"] !== undefined) {
if (typeof t["model_id"] !== "string" || t["model_id"] === "") {
return { ok: false, error: "model_id 必须是非空字符串" };
return { ok: false, error: "model_id must be a non-empty string" };
}
modelId = t["model_id"];
}
let provider: string | undefined;
if (t["provider"] !== undefined) {
if (typeof t["provider"] !== "string" || t["provider"] === "") {
return { ok: false, error: "provider 必须是非空字符串" };
return { ok: false, error: "provider must be a non-empty string" };
}
provider = t["provider"];
}
if (provider !== undefined && modelId === undefined) {
return { ok: false, error: "provider 仅与 model_id 成对使用(模型引用须成对给出)" };
return {
ok: false,
error:
"provider is only used together with model_id (a model reference must be given as a pair)",
};
}
if (
sessionId !== undefined &&
@@ -155,7 +161,7 @@ export function parseScheduleFile(name: string, raw: string): ScheduleParseResul
) {
return {
ok: false,
error: "目标二选一:workspace 与 provider / model_id 仅用于新建 Session 模式",
error: "Pick one target: workspace and provider / model_id are only for new-Session mode",
};
}
+34 -13
View File
@@ -52,9 +52,10 @@ import type { UsageContext } from "./usage-recorder.js";
/** 409 for when there's nothing to compact: give the specific reason rather than a one-size-fits-none message. */
function compactUnavailable(why: Exclude<CompactAvailability, "ok">): HttpError {
const messages: Record<typeof why, string> = {
unsupported: "该 Agent 未配置上下文压缩能力。",
empty: "当前上下文没有可压缩的内容(尚无已完成的对话轮次)。",
just_compacted: "上下文刚压缩过,此后还没有新的对话,无需再次压缩。",
unsupported: "This Agent does not have context compaction configured.",
empty: "The current context has nothing to compact (no completed conversation turns yet).",
just_compacted:
"The context was just compacted and there is no new conversation since; no need to compact again.",
};
return new HttpError(409, "nothing_to_compact", messages[why]);
}
@@ -129,7 +130,7 @@ export function createCoreSessionLoader(root: string): SessionLoader {
throw new HttpError(
409,
"workspace_missing",
`该 Session 的 Workspace 已不存在:${row.workspace},无法继续。请新建 Session。`,
`This Session's Workspace no longer exists: ${row.workspace}, so it cannot continue. Create a new Session.`,
);
}
try {
@@ -533,7 +534,11 @@ export class SessionManager {
private assertOpen(): void {
if (this.closed) {
throw new HttpError(503, "shutting_down", "服务端正在关停,暂不接受新任务。");
throw new HttpError(
503,
"shutting_down",
"Server is shutting down; not accepting new Tasks.",
);
}
}
@@ -541,23 +546,35 @@ export class SessionManager {
private assertAgentNotDeleting(sessionId: string): void {
const row = this.deps.sessions.findById(sessionId);
if (row && this.deletingAgents.has(agentKey(row.projectId, row.agentId))) {
throw new HttpError(409, "agent_deleting", "该 Agent 正在删除,暂不接受新任务。");
throw new HttpError(
409,
"agent_deleting",
"This Agent is being deleted; not accepting new Tasks.",
);
}
}
/** This Session is being deleted → 409 (guards against the entry/Trace being rebuilt and reviving it inside the deletion race window). */
private assertSessionNotDeleting(sessionId: string): void {
if (this.deletingSessions.has(sessionId)) {
throw new HttpError(409, "session_deleting", "该 Session 正在删除,暂不接受新任务。");
throw new HttpError(
409,
"session_deleting",
"This Session is being deleted; not accepting new Tasks.",
);
}
}
private assertIdle(entry: RuntimeEntry): void {
if (entry.status === "running") {
throw new HttpError(409, "task_in_progress", "该 Session 已有进行中的 Task。");
throw new HttpError(409, "task_in_progress", "This Session already has a Task in progress.");
}
if (entry.status === "compacting") {
throw new HttpError(409, "compacting", "该 Session 正在压缩上下文,暂不接受新输入。");
throw new HttpError(
409,
"compacting",
"This Session is compacting its context; not accepting new input.",
);
}
}
@@ -567,7 +584,11 @@ export class SessionManager {
if (existing) return existing;
const row = this.deps.sessions.findById(sessionId);
if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
throw new HttpError(
404,
"session_not_found",
"Session does not exist or you do not have access.",
);
}
const session = await this.deps.loader.load(row);
// The Session/Agent was marked for deletion while loading: discard the load result,
@@ -676,7 +697,7 @@ export class SessionManager {
}
} catch (err) {
this.log(
`[subagent] 子会话登记失败: ${err instanceof Error ? err.message : String(err)}`,
`[subagent] Failed to register child session: ${err instanceof Error ? err.message : String(err)}`,
);
this.deps.errors?.record({
source: "subagent",
@@ -702,7 +723,7 @@ export class SessionManager {
try {
await this.deps.recorder.record(ctx, msg);
} catch (err) {
this.log(`[usage] 落库失败: ${err instanceof Error ? err.message : String(err)}`);
this.log(`[usage] Insert failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" });
}
}
@@ -710,7 +731,7 @@ export class SessionManager {
// The SDK doesn't normally throw (errors are converged into the message stream);
// this is a defensive record here to avoid crashing the runtime.
this.log(
`[session] 运行异常: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
`[session] Run failed: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
);
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
} finally {
@@ -75,12 +75,16 @@ type LlmFailure = "failed" | "timeout" | "malformed";
/** LLM failure state → error code, classification, and fallback message (used when the abort reason isn't available). */
const LLM_FAILURES: Record<LlmFailure, { code: string; kind: ErrorKind; text: string }> = {
failed: { code: "llm_failed", kind: "unexpected", text: "LLM 请求失败(不可重试)。" },
timeout: { code: "llm_timeout", kind: "expected", text: "LLM 请求超时(引擎重连重试)。" },
failed: { code: "llm_failed", kind: "unexpected", text: "LLM request failed (not retryable)." },
timeout: {
code: "llm_timeout",
kind: "expected",
text: "LLM request timed out (the engine reconnects and retries).",
},
malformed: {
code: "llm_malformed",
kind: "expected",
text: "LLM 响应无法解析(引擎重连重试)。",
text: "LLM response could not be parsed (the engine reconnects and retries).",
},
};
@@ -113,7 +117,7 @@ function originKey(msg: OmniMessage): string {
* drop the reason.
*/
function toolFailureText(output: string): string {
if (!output) return "工具执行失败(无输出)。";
if (!output) return "Tool execution failed (no output).";
if (output.length <= MESSAGE_MAX) return output;
return `…${output.slice(output.length - (MESSAGE_MAX - 1))}`;
}
@@ -71,7 +71,7 @@ export class TitleGenerator implements TitleNotifier {
this.inflight.add(ctx.sessionId);
void this.generate(ctx, session, req)
.catch((err: unknown) => {
this.log(`[title] 生成失败: ${err instanceof Error ? err.message : String(err)}`);
this.log(`[title] Generation failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "title", err, ctx, code: "title_failed" });
})
.finally(() => {
@@ -97,7 +97,9 @@ export class TitleGenerator implements TitleNotifier {
try {
await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage));
} catch (err) {
this.log(`[title] 用量落库失败: ${err instanceof Error ? err.message : String(err)}`);
this.log(
`[title] Usage insert failed: ${err instanceof Error ? err.message : String(err)}`,
);
this.deps.errors?.record({
source: "title",
err,
@@ -109,7 +111,7 @@ export class TitleGenerator implements TitleNotifier {
} catch (err) {
// A model request error (rate limit / timeout / network, etc.) shouldn't leave the
// title permanently missing: log it and fall through to the fallback.
this.log(`[title] 模型请求失败: ${err instanceof Error ? err.message : String(err)}`);
this.log(`[title] Model request failed: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "title", err, ctx, code: "title_llm_failed" });
}
// When the LLM produces no usable title (failure / empty result), truncate the fallback material's first line — this guarantees a title is always generated.
+11 -7
View File
@@ -41,13 +41,17 @@ export class AdminService {
async createUser(userId: string, password: string): Promise<UserInfo> {
if (!USERNAME_PATTERN.test(userId)) {
throw new HttpError(400, "invalid_user_id", `用户名须为 2~32 位:${SEMANTIC_ID_RULE}。`);
throw new HttpError(
400,
"invalid_user_id",
`Username must be 2–32 characters: ${SEMANTIC_ID_RULE}.`,
);
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
if (this.deps.users.findById(userId)) {
throw new HttpError(409, "user_exists", `用户已存在:${userId}。`);
throw new HttpError(409, "user_exists", `User already exists: ${userId}.`);
}
const user: UserRow = {
userId,
@@ -70,10 +74,10 @@ export class AdminService {
/** Reset another user's password: flags it as initial and clears all their sessions (prompts a password change on next login). */
async resetPassword(userId: string, password: string): Promise<void> {
if (!this.deps.users.findById(userId)) {
throw new HttpError(404, "user_not_found", `用户不存在:${userId}。`);
throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
this.deps.users.updatePassword(userId, await hashPassword(password), true);
this.deps.authSessions.deleteByUser(userId);
@@ -83,10 +87,10 @@ export class AdminService {
async deleteUser(userId: string): Promise<void> {
const target = this.deps.users.findById(userId);
if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${userId}。`);
throw new HttpError(404, "user_not_found", `User does not exist: ${userId}.`);
}
if (target.isAdmin) {
throw new HttpError(409, "cannot_delete_admin", "内置管理员不能删除。");
throw new HttpError(409, "cannot_delete_admin", "The built-in admin cannot be deleted.");
}
for (const project of this.deps.projects.listByOwner(userId)) {
await this.deps.projectService.destroyProject(project.projectId);
@@ -68,7 +68,7 @@ export class AgentConfigService {
async requireExists(projectId: string, agentId: string): Promise<void> {
if (!(await this.exists(projectId, agentId))) {
throw new HttpError(404, "agent_not_found", "Agent 不存在。");
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
}
}
@@ -269,11 +269,11 @@ export class AgentConfigService {
for (const entry of req.entries) {
if (!isValidVaultKey(entry.key)) {
throw badRequest(
`vault 键名不合法:${entry.key}(仅字母、数字与下划线,且不能以数字开头)。`,
`Invalid vault key name: ${entry.key} (letters, digits and underscores only, and must not start with a digit).`,
);
}
if (seen.has(entry.key)) {
throw badRequest(`entries 中存在重复的键名:${entry.key}。`);
throw badRequest(`entries contains a duplicate key: ${entry.key}.`);
}
seen.add(entry.key);
const prevValue = prev[entry.key];
@@ -281,13 +281,15 @@ export class AgentConfigService {
// Values are injected into the child process environment: an oversized value would
// make exec spawn fail (E2BIG), so we reject it on write (same limit as core).
if (entry.value.length > VAULT_VALUE_MAX_LENGTH) {
throw badRequest(`vault 值过长:${entry.key}(上限 ${VAULT_VALUE_MAX_LENGTH} 字符)。`);
throw badRequest(
`Vault value too long: ${entry.key} (limit ${VAULT_VALUE_MAX_LENGTH} characters).`,
);
}
nextVault[entry.key] = entry.value;
} else if (prevValue !== undefined) {
nextVault[entry.key] = prevValue;
} else {
throw badRequest(`新增键 ${entry.key} 必须提供 value。`);
throw badRequest(`New key ${entry.key} must provide a value.`);
}
}
@@ -297,20 +299,20 @@ export class AgentConfigService {
}
function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
if (!Array.isArray(value)) throw badRequest("toolsBuiltin 必须是数组。");
if (!Array.isArray(value)) throw badRequest("toolsBuiltin must be an array.");
return value.map((item, i) => {
const t = asRecord(item);
if (typeof t.name !== "string" || t.name.length === 0) {
throw badRequest(`toolsBuiltin[${i}].name 必须是非空字符串。`);
throw badRequest(`toolsBuiltin[${i}].name must be a non-empty string.`);
}
if (typeof t.description !== "string") {
throw badRequest(`toolsBuiltin[${i}].description 必须是字符串。`);
throw badRequest(`toolsBuiltin[${i}].description must be a string.`);
}
if (t.permission !== undefined && t.permission !== "r" && t.permission !== "rw") {
throw badRequest(`toolsBuiltin[${i}].permission 必须是 r / rw 之一。`);
throw badRequest(`toolsBuiltin[${i}].permission must be one of r / rw.`);
}
if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") {
throw badRequest(`toolsBuiltin[${i}].forModel 必须是 vision / text-only 之一。`);
throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
}
optionalNumber(t, "timeoutMs", {
integer: true,
@@ -327,14 +329,14 @@ function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
}
function validateMcpServers(value: unknown): MCPServerConfig[] {
if (!Array.isArray(value)) throw badRequest("mcpServers 必须是数组。");
if (!Array.isArray(value)) throw badRequest("mcpServers must be an array.");
return value.map((item, i) => {
const s = asRecord(item);
if (typeof s.name !== "string" || s.name.length === 0) {
throw badRequest(`mcpServers[${i}].name 必须是非空字符串。`);
throw badRequest(`mcpServers[${i}].name must be a non-empty string.`);
}
if (s.config === null || typeof s.config !== "object" || Array.isArray(s.config)) {
throw badRequest(`mcpServers[${i}].config 必须是对象。`);
throw badRequest(`mcpServers[${i}].config must be an object.`);
}
return s as unknown as MCPServerConfig;
});
@@ -155,7 +155,7 @@ export class AgentService {
throw new HttpError(
409,
"cannot_delete_builtin_agent",
"内置 Agent(default_agent)随 Project 供给,不能从 Web 删除。",
"Built-in Agents (default_agent) are provisioned with the Project and cannot be deleted from the web.",
);
}
await fs.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
@@ -175,7 +175,11 @@ export class AgentService {
description?: string,
): Promise<AgentListItem> {
if (!SEMANTIC_ID_PATTERN.test(agentId)) {
throw new HttpError(400, "invalid_agent_id", `Agent id 须为 2~64 位:${SEMANTIC_ID_RULE}。`);
throw new HttpError(
400,
"invalid_agent_id",
`Agent id must be 2–64 characters: ${SEMANTIC_ID_RULE}.`,
);
}
const taken =
this.agents.exists(projectId, agentId) ||
@@ -184,7 +188,7 @@ export class AgentService {
() => false,
));
if (taken) {
throw new HttpError(409, "agent_exists", `Agent id 已被占用:${agentId}。`);
throw new HttpError(409, "agent_exists", `Agent id is already taken: ${agentId}.`);
}
const displayName = name ?? agentId;
await coreCreateAgent({ root: this.root, projectId, agentId });
+2 -1
View File
@@ -31,4 +31,5 @@ export const PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
export const PROJECT_ID_MAX_LENGTH = 64;
/** Human-readable description of the rule (reused in error messages). */
export const SEMANTIC_ID_RULE = "小写字母开头,仅小写字母、数字与下划线";
export const SEMANTIC_ID_RULE =
"starts with a lowercase letter; lowercase letters, digits and underscores only";
@@ -364,7 +364,7 @@ export class ProjectConfigService {
const key = refKey(entry.provider, entry.modelId);
if (seen.has(key)) {
throw badRequest(
`models 中存在重复的模型引用:${showRef(entry.provider, entry.modelId)}。`,
`models contains a duplicate model reference: ${showRef(entry.provider, entry.modelId)}.`,
);
}
seen.add(key);
@@ -436,7 +436,7 @@ export class ProjectConfigService {
if (req.defaultModel !== undefined) {
if (!seen.has(refKey(req.defaultModel.provider, req.defaultModel.modelId))) {
throw badRequest(
`defaultModel 必须包含在 models 内:${showRef(req.defaultModel.provider, req.defaultModel.modelId)}。`,
`defaultModel must be included in models: ${showRef(req.defaultModel.provider, req.defaultModel.modelId)}.`,
);
}
defaultModel = req.defaultModel;
@@ -459,12 +459,12 @@ export class ProjectConfigService {
if (req.visionModel !== undefined) {
if (!seen.has(refKey(req.visionModel.provider, req.visionModel.modelId))) {
throw badRequest(
`visionModel 必须包含在 models 内:${showRef(req.visionModel.provider, req.visionModel.modelId)}。`,
`visionModel must be included in models: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`,
);
}
if (targetOf(req.visionModel)?.vision === false) {
throw badRequest(
`visionModel 不能指向标注为不支持图片的模型:${showRef(req.visionModel.provider, req.visionModel.modelId)}。`,
`visionModel must not point to a model annotated as not supporting images: ${showRef(req.visionModel.provider, req.visionModel.modelId)}.`,
);
}
visionModel = req.visionModel;
+28 -12
View File
@@ -84,7 +84,11 @@ export class ProjectService {
requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } {
const row = this.resolveAccess(userId, projectId);
if (!row) {
throw new HttpError(404, "project_not_found", "Project 不存在或无权访问。");
throw new HttpError(
404,
"project_not_found",
"Project does not exist or you do not have access.",
);
}
return row;
}
@@ -103,7 +107,11 @@ export class ProjectService {
requireProjectOwner(userId: string, projectId: string): ProjectRow {
const row = this.requireProjectAccess(userId, projectId);
if (row.role !== "owner") {
throw new HttpError(403, "owner_required", "该操作仅 Project owner 可执行。");
throw new HttpError(
403,
"owner_required",
"Only the Project owner can perform this operation.",
);
}
return row;
}
@@ -149,7 +157,7 @@ export class ProjectService {
throw new HttpError(
400,
"invalid_project_id",
`Project id 须为 2~64 位:${SEMANTIC_ID_RULE}(连字符保留作用户命名空间分隔)。`,
`Project id must be 2–64 characters: ${SEMANTIC_ID_RULE} (the hyphen is reserved as the user namespace separator).`,
);
}
} else {
@@ -159,7 +167,7 @@ export class ProjectService {
throw new HttpError(
400,
"project_id_prefix_required",
`Project id 须以 ${prefix} 开头,后接小写字母、数字或下划线(总长不超过 ${PROJECT_ID_MAX_LENGTH})。`,
`Project id must start with ${prefix}, followed by lowercase letters, digits or underscores (at most ${PROJECT_ID_MAX_LENGTH} in total).`,
);
}
}
@@ -167,7 +175,7 @@ export class ProjectService {
this.deps.projects.findById(projectId) !== null ||
(await dirExists(projectDir(this.deps.root, projectId)))
) {
throw new HttpError(409, "project_exists", `Project id 已被占用:${projectId}。`);
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
}
const displayName = name ?? projectId;
const createdAt = new Date().toISOString();
@@ -179,7 +187,7 @@ export class ProjectService {
this.deps.projects.insert({ projectId, ownerUserId: owner.userId, createdAt });
} catch (err) {
if (err instanceof Error && err.message.includes("UNIQUE")) {
throw new HttpError(409, "project_exists", `Project id 已被占用:${projectId}。`);
throw new HttpError(409, "project_exists", `Project id is already taken: ${projectId}.`);
}
throw err;
}
@@ -248,14 +256,14 @@ export class ProjectService {
throw new HttpError(
409,
"cannot_delete_default_project",
"default_project 与 CLI 共用,不能从 Web 删除。",
"default_project is shared with the CLI and cannot be deleted from the web.",
);
}
if (this.deps.projects.listAccessible(userId).length <= 1) {
throw new HttpError(
409,
"cannot_delete_last_project",
"这是当前账号最后一个 Project,删除后将无 Project 可用;请先创建新的 Project。",
"This is the account's last Project; deleting it would leave no Project available. Create a new Project first.",
);
}
await this.destroyProject(projectId);
@@ -306,13 +314,21 @@ export class ProjectService {
const project = this.requireProjectOwner(userId, projectId);
const target = this.deps.users.findById(targetUserId);
if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${targetUserId}。`);
throw new HttpError(404, "user_not_found", `User does not exist: ${targetUserId}.`);
}
if (target.userId === project.ownerUserId) {
throw new HttpError(409, "already_owner", "owner 无需授权给自己。");
throw new HttpError(
409,
"already_owner",
"The owner does not need to grant access to themselves.",
);
}
if (this.deps.members.isMember(projectId, target.userId)) {
throw new HttpError(409, "already_member", `${targetUserId} 已是该 Project 的成员。`);
throw new HttpError(
409,
"already_member",
`${targetUserId} is already a member of this Project.`,
);
}
const createdAt = new Date().toISOString();
this.deps.members.insert({ projectId, userId: target.userId, createdAt });
@@ -323,7 +339,7 @@ export class ProjectService {
removeMember(userId: string, projectId: string, targetUserId: string): void {
this.requireProjectOwner(userId, projectId);
if (!this.deps.members.isMember(projectId, targetUserId)) {
throw new HttpError(404, "member_not_found", `该 Project 没有成员:${targetUserId}。`);
throw new HttpError(404, "member_not_found", `This Project has no member: ${targetUserId}.`);
}
this.deps.members.delete(projectId, targetUserId);
}
@@ -168,7 +168,7 @@ export class SessionService {
throw new HttpError(
400,
"no_default_model",
"该 Project 尚未配置默认模型,请先在「模型」页添加模型并设为默认。",
"This Project has no default model yet. Add a model on the Models page and set it as the default first.",
);
}
modelId = def.model_id;
@@ -42,7 +42,7 @@ export class SnapshotService {
try {
raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
} catch {
throw new HttpError(404, "agent_not_found", "Agent 不存在。");
throw new HttpError(404, "agent_not_found", "Agent does not exist.");
}
const parsed = parseYaml(raw) as { version?: unknown } | null;
return agentStateVersion({
@@ -117,7 +117,7 @@ export class SnapshotService {
filter: (p) => !isVaultEntry(p),
});
} catch {
throw badRequest("导入失败:不是合法的 tar.gz 快照包。");
throw badRequest("Import failed: not a valid tar.gz snapshot package.");
}
// Validation: the package must contain agent_state/system_config.yaml, and version must be valid.
@@ -126,21 +126,21 @@ export class SnapshotService {
try {
parsed = parseYaml(await fs.readFile(configPath, "utf8"));
} catch {
throw badRequest("导入失败:包内缺少 agent_state/system_config.yaml。");
throw badRequest("Import failed: the package is missing agent_state/system_config.yaml.");
}
if (
parsed === null ||
typeof parsed !== "object" ||
typeof (parsed as { system_prompt?: unknown }).system_prompt !== "string"
) {
throw badRequest("导入失败:包内 system_config.yaml 非法。");
throw badRequest("Import failed: the package's system_config.yaml is invalid.");
}
const incomingRaw = (parsed as { version?: unknown }).version;
if (
incomingRaw !== undefined &&
(!Number.isInteger(incomingRaw) || (incomingRaw as number) < 1)
) {
throw badRequest("导入失败:包内 version 非法。");
throw badRequest("Import failed: the package's version is invalid.");
}
const incoming = agentStateVersion({
version: typeof incomingRaw === "number" ? incomingRaw : undefined,
@@ -149,7 +149,7 @@ export class SnapshotService {
throw new HttpError(
409,
"version_conflict",
`包版本 v${incoming} 不高于当前 v${current},需要确认后导入。`,
`Package version v${incoming} is not higher than the current v${current}; confirmation is required to import.`,
);
}
@@ -675,7 +675,7 @@ export class TraceService {
throw new HttpError(
404,
"trace_not_found",
`该 Session 没有 index 为 ${index} 的 Trace 文件。`,
`This Session has no Trace file with index ${index}.`,
);
}
return readTraceTolerant(file.path);
@@ -56,7 +56,7 @@ export class WorkspaceFilesService {
try {
return await fs.realpath(path.resolve(workspace));
} catch {
throw new HttpError(404, "workspace_missing", "该 Session 的 Workspace 已不存在。");
throw new HttpError(404, "workspace_missing", "This Session's Workspace no longer exists.");
}
}
@@ -74,17 +74,17 @@ export class WorkspaceFilesService {
/** Lexical prefix check (a relative path, once resolved, must still be inside the Workspace); returns the absolute target path. */
private lexicalTarget(base: string, rel: string): string {
if (rel.includes("\0")) throw badRequest("path 非法。");
if (rel.includes("\0")) throw badRequest("path is invalid.");
const target = path.resolve(base, rel === "" ? "." : rel);
if (!this.isInside(target, base)) {
throw badRequest("path 必须位于 Workspace 内。");
throw badRequest("path must be inside the Workspace.");
}
return target;
}
private assertInside(real: string, realBase: string): void {
if (!this.isInside(real, realBase)) {
throw badRequest("path 必须位于 Workspace 内。");
throw badRequest("path must be inside the Workspace.");
}
}
@@ -103,7 +103,7 @@ export class WorkspaceFilesService {
canonical = await fs.realpath(target);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "文件不存在。");
throw new HttpError(404, "path_not_found", "File does not exist.");
}
throw err;
}
@@ -132,7 +132,7 @@ export class WorkspaceFilesService {
const realBase = await this.realBase(workspace);
const target = this.lexicalTarget(path.resolve(workspace), rel);
const name = path.basename(target);
if (name === "" || name === "." || name === "..") throw badRequest("path 必须是文件路径。");
if (name === "" || name === "." || name === "..") throw badRequest("path must be a file path.");
const parent = path.dirname(target);
let canonicalParent: string;
try {
@@ -147,7 +147,7 @@ export class WorkspaceFilesService {
} catch (probeErr) {
if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr;
const up = path.dirname(probe);
if (up === probe) throw badRequest("path 非法。");
if (up === probe) throw badRequest("path is invalid.");
probe = up;
}
}
@@ -189,10 +189,10 @@ export class WorkspaceFilesService {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "目录不存在。");
throw new HttpError(404, "path_not_found", "Directory does not exist.");
}
if ((err as NodeJS.ErrnoException).code === "ENOTDIR") {
throw badRequest("path 不是目录。");
throw badRequest("path is not a directory.");
}
throw err;
}
@@ -231,11 +231,11 @@ export class WorkspaceFilesService {
try {
stat = await fs.stat(file);
} catch {
throw new HttpError(404, "path_not_found", "文件不存在。");
throw new HttpError(404, "path_not_found", "File does not exist.");
}
if (stat.isDirectory()) throw badRequest("path 是目录。");
if (stat.isDirectory()) throw badRequest("path is a directory.");
if (stat.size > MAX_READ_BYTES) {
throw new HttpError(413, "file_too_large", "文件超过 50MB 读取上限。");
throw new HttpError(413, "file_too_large", "File exceeds the 50MB read limit.");
}
const data = await fs.readFile(file);
const ext = path.extname(file).toLowerCase();
@@ -256,9 +256,9 @@ export class WorkspaceFilesService {
* sandbox escapes).
*/
async write(workspace: string, rel: string, data: Buffer): Promise<void> {
if (rel === "" || rel.endsWith("/")) throw badRequest("path 必须是文件路径。");
if (rel === "" || rel.endsWith("/")) throw badRequest("path must be a file path.");
if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。");
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
}
const { dir, name } = await this.resolveWriteParent(workspace, rel);
const file = path.join(dir, name);
@@ -269,9 +269,10 @@ export class WorkspaceFilesService {
handle = await fs.open(file, flags, 0o644);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ELOOP") throw badRequest("path 不能是符号链接。");
if (code === "ENOENT") throw new HttpError(404, "path_not_found", "父目录不存在。");
if (code === "EISDIR") throw badRequest("path 是目录。");
if (code === "ELOOP") throw badRequest("path must not be a symlink.");
if (code === "ENOENT")
throw new HttpError(404, "path_not_found", "Parent directory does not exist.");
if (code === "EISDIR") throw badRequest("path is a directory.");
throw err;
}
try {
@@ -26,12 +26,16 @@ export async function assertWorkspaceAllowed(args: { workspace: string }): Promi
throw new HttpError(
400,
"workspace_not_found",
`Workspace 不存在或不可访问:${args.workspace}。请指定一个已存在的目录,或留空以使用临时目录。`,
`Workspace does not exist or is inaccessible: ${args.workspace}. Specify an existing directory, or leave it empty to use a temporary directory.`,
);
}
const stat = await fs.stat(ws);
if (!stat.isDirectory()) {
throw new HttpError(400, "workspace_not_found", `Workspace 不是目录:${args.workspace}。`);
throw new HttpError(
400,
"workspace_not_found",
`Workspace is not a directory: ${args.workspace}.`,
);
}
return ws;
}