Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Unified HTTP error: `{error: {code, message}}` response body,
|
||||
* with message in Chinese.
|
||||
*
|
||||
* 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
|
||||
* (never leaking internal details to the client).
|
||||
*/
|
||||
import type { Context } from "hono";
|
||||
import type { ErrorBody } from "../api/types.js";
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
export function errorBody(code: string, message: string): ErrorBody {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Model missing a credential: the provider SDK throws this at **client-construction
|
||||
* time** (hit by both creating a Session and resuming a Session); the original message
|
||||
* is full of environment variable names, meaningless to a user — uniformly replaced
|
||||
* with a single actionable sentence. The frontend produces localized text by code
|
||||
* (message is only a fallback); see web's lib/api-error.ts.
|
||||
*/
|
||||
export function isMissingCredential(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /missing credentials|api[_ ]?key/i.test(message);
|
||||
}
|
||||
|
||||
export function modelCredentialMissing(modelId: string): HttpError {
|
||||
return new HttpError(
|
||||
400,
|
||||
"model_credential_missing",
|
||||
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置。`,
|
||||
);
|
||||
}
|
||||
|
||||
/** app.onError handler: maps HttpError through as-is; everything else is logged and converged to 500. */
|
||||
export function handleError(err: Error, c: Context): Response {
|
||||
if (err instanceof HttpError) {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Admin user-backend routes: only the built-in admin can use these (403 for non-admins).
|
||||
* GET|POST /api/admin/users, POST /api/admin/users/:userId/password, DELETE /api/admin/users/:userId.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { pathParam, readJson, requireString } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (!c.var.user.isAdmin) {
|
||||
throw new HttpError(403, "admin_required", "该操作仅管理员可执行。");
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.json({ users: deps.adminService.listUsers() } satisfies AdminUsersResponse);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const userId = requireString(body, "userId", { label: "userId" });
|
||||
const password = requireString(body, "password", { label: "password" });
|
||||
const user = await deps.adminService.createUser(userId, password);
|
||||
return c.json({ user } satisfies AdminUserCreateResponse, 201);
|
||||
});
|
||||
|
||||
app.post("/:userId/password", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const password = requireString(body, "password", { label: "password" });
|
||||
await deps.adminService.resetPassword(pathParam(c, "userId"), password);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
app.delete("/:userId", async (c) => {
|
||||
await deps.adminService.deleteUser(pathParam(c, "userId"));
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Agent config routes (reads/writes system_config.yaml and AGENTS.md):
|
||||
* GET|PUT /api/projects/:p/agents/:a/config. Members can read and write (unrestricted).
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AgentConfigResponse, AgentConfigUpdateRequest } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { badRequest, optionalString, readJson, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function agentConfigRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const view = await deps.agentConfigService.getConfig(projectId, agentId);
|
||||
return c.json({
|
||||
...view,
|
||||
activeSessionCount: deps.manager.activeCountForAgent(projectId, agentId),
|
||||
} satisfies AgentConfigResponse);
|
||||
});
|
||||
|
||||
app.put("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const body = await readJson(c);
|
||||
const req: AgentConfigUpdateRequest = {};
|
||||
const agentsMd = optionalString(body, "agentsMd", { label: "agentsMd" });
|
||||
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 必须是对象。");
|
||||
}
|
||||
req.config = body.config as AgentConfigUpdateRequest["config"];
|
||||
}
|
||||
// Fine-grained validation (numeric ranges / enums) is done inside agent-config-service.
|
||||
await deps.agentConfigService.updateConfig(projectId, agentId, req);
|
||||
const view = await deps.agentConfigService.getConfig(projectId, agentId);
|
||||
return c.json({
|
||||
...view,
|
||||
activeSessionCount: deps.manager.activeCountForAgent(projectId, agentId),
|
||||
} satisfies AgentConfigResponse);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Agent-level Trace browsing routes:
|
||||
* - GET /api/projects/:p/agents/:a/traces — drills down Agent -> date -> Session -> index (reverse order);
|
||||
* - GET /api/projects/:p/agents/:a/traces/:sessionId/:index (including /analysis) —
|
||||
* read-only Trace detail endpoints (FD-3): locate the Trace file directly by
|
||||
* (projectId, agentId, sessionId), without depending on the sessions table for
|
||||
* tracking — any entry visible in the directory tree (subagent child Sessions,
|
||||
* CLI-created Sessions) can be opened and read; access is enforced by requireProjectAccess.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { paginationQuery, positiveIntParam, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function agentTracesRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
return c.json(await deps.traceService.agentTraces(projectId, agentId));
|
||||
});
|
||||
|
||||
app.get("/:sessionId/:index", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
const sessionId = requireValidId(c, "sessionId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const index = positiveIntParam(c, "index");
|
||||
const { offset, limit } = paginationQuery(c);
|
||||
return c.json(
|
||||
await deps.traceService.readEvents(projectId, agentId, sessionId, index, offset, limit),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/:sessionId/:index/analysis", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
const sessionId = requireValidId(c, "sessionId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const index = positiveIntParam(c, "index");
|
||||
return c.json(await deps.traceService.analyze(projectId, agentId, sessionId, index));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Agent State export/import routes:
|
||||
* GET /api/projects/:p/agents/:a/export (any member; auto-packages if no snapshot exists, downloads tar.gz)
|
||||
* POST /api/projects/:p/agents/:a/import (owner only; version conflicts require a confirm flag)
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import { Hono } from "hono";
|
||||
import type { AgentImportResponse } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
|
||||
|
||||
/** Import archive size cap: aligned with the global request body limit (stays within 20MB after base64). */
|
||||
const MAX_ARCHIVE_BYTES = 14 * 1024 * 1024;
|
||||
|
||||
export function agentTransferRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/export", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const { file, fileName } = await deps.snapshots.exportArchive(projectId, agentId);
|
||||
const bytes = await fs.readFile(file);
|
||||
return new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/import", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
const body = await readJson(c);
|
||||
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
|
||||
const confirm = body.confirm === true;
|
||||
let archive: Buffer;
|
||||
try {
|
||||
archive = Buffer.from(dataBase64, "base64");
|
||||
} catch {
|
||||
throw badRequest("dataBase64 不是合法的 base64。");
|
||||
}
|
||||
if (archive.byteLength === 0) throw badRequest("导入包为空。");
|
||||
if (archive.byteLength > MAX_ARCHIVE_BYTES) throw badRequest("导入包超过 14MB 上限。");
|
||||
const { version } = await deps.snapshots.importArchive(projectId, agentId, archive, confirm);
|
||||
const res: AgentImportResponse = { version };
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Agent routes:
|
||||
* GET|POST /api/projects/:p/agents, DELETE /:agentId (owner only).
|
||||
* The list is the union of DB entries and directory scan results, including active
|
||||
* Session count, total Session count, and config last-modified time.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AgentCreateResponse, AgentsResponse, AgentSummary } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { settleWithin } from "../settle.js";
|
||||
import { optionalString, readJson, requireString, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
/** Window size in days for the card's activity sparkline (last 30 days, including today). */
|
||||
const ACTIVITY_DAYS = 30;
|
||||
|
||||
export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation (FD-4): don't rely on the implicit invariant that requireProjectAccess always runs before path construction.
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const items = await deps.agentService.listAgents(projectId);
|
||||
const agents: AgentSummary[] = await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const stats = await deps.sessionService.sessionStats(
|
||||
projectId,
|
||||
item.agentId,
|
||||
ACTIVITY_DAYS,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
activeSessionCount: deps.manager.activeCountForAgent(projectId, item.agentId),
|
||||
sessionCount: stats.sessionCount,
|
||||
sessionActivity: stats.activity,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return c.json({ agents } satisfies AgentsResponse);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const body = await readJson(c);
|
||||
const agentId = requireString(body, "agentId", { label: "agentId" });
|
||||
const name = optionalString(body, "name", { minLen: 1, maxLen: 100, label: "name" });
|
||||
const description = optionalString(body, "description", {
|
||||
maxLen: 2000,
|
||||
label: "description",
|
||||
});
|
||||
const item = await deps.agentService.createAgent(projectId, agentId, name, description);
|
||||
const agent: AgentSummary = {
|
||||
...item,
|
||||
activeSessionCount: 0,
|
||||
sessionCount: 0,
|
||||
sessionActivity: Array.from({ length: ACTIVITY_DAYS }, () => 0),
|
||||
};
|
||||
return c.json({ agent } satisfies AgentCreateResponse, 201);
|
||||
});
|
||||
|
||||
app.delete("/:agentId", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
// Deletion is a Project-level management operation: owner only.
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
// Mark as deleting and converge active runs (beginAgentDeletion): any new Task during
|
||||
// this window gets 409, preventing the race where a new task recreates the directory
|
||||
// and revives the Agent between abort and rm. Abort cleanup writes the Trace
|
||||
// asynchronously; wait for it to finish before removing the directory, and clear the
|
||||
// deleting flag once deletion completes (success or failure).
|
||||
const runnings = deps.manager.beginAgentDeletion(projectId, agentId);
|
||||
try {
|
||||
await settleWithin(runnings, 5000);
|
||||
await deps.agentService.deleteAgent(projectId, agentId);
|
||||
deps.sessionsRepo.deleteByAgent(projectId, agentId);
|
||||
} finally {
|
||||
deps.manager.endAgentDeletion(projectId, agentId);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Auth routes: POST /api/auth/login | logout.
|
||||
* No self-registration: users are created by an admin in the user backend (/api/admin/users).
|
||||
* Login issues a cookie session; logout deletes the server-side session and clears the cookie.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import type { AuthResponse } from "../../api/types.js";
|
||||
import { SESSION_COOKIE } from "../../auth/middleware.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { readJson, requireString } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
/** Session cookie attributes: HttpOnly, SameSite=Lax, 7 days. */
|
||||
function cookieOptions(c: { req: { header(name: string): string | undefined } }) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax" as const,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
// Add Secure when the reverse proxy declares https.
|
||||
...(c.req.header("x-forwarded-proto") === "https" ? { secure: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function authRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.post("/login", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const userId = requireString(body, "userId", { label: "userId" });
|
||||
const password = requireString(body, "password", { label: "password" });
|
||||
const { user, token } = await deps.authService.login(userId, password);
|
||||
setCookie(c, SESSION_COOKIE, token, cookieOptions(c));
|
||||
return c.json({ user } satisfies AuthResponse);
|
||||
});
|
||||
|
||||
app.post("/logout", (c) => {
|
||||
const token = getCookie(c, SESSION_COOKIE);
|
||||
if (token) deps.authService.logout(token);
|
||||
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Benchmark scoring routes:
|
||||
* GET /api/projects/:p/agents/:a/benchmarks (any member, read-only)
|
||||
* Returns the Agent's Benchmark list (title/description from benchmark_config.toml)
|
||||
* along with the evaluations[] from scoreboard.yaml.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
import { requireValidId } from "../validate.js";
|
||||
|
||||
export function benchmarksRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
return c.json(await deps.benchmarks.list(projectId, agentId));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Server directory browsing:
|
||||
* GET /api/projects/:p/dirs?path=<absolute>.
|
||||
*
|
||||
* Lets the user interactively pick a Workspace directory when creating a Session via
|
||||
* advanced mode. Defaults to the home directory of the account running the service, and
|
||||
* can be browsed all the way up to the root `/` — reachability is governed by OS file
|
||||
* permissions; the server no longer restricts browsing to within the Project directory
|
||||
* tree (same convention as workspace-guard). Lists subdirectories only, not files.
|
||||
*
|
||||
* `projectId` remains the authorization anchor: the caller must have access to that Project.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import type { DirListResponse } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function dirsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
|
||||
// Default starting point: home directory; an explicit path must be absolute (the frontend always sends back the realpath result).
|
||||
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", "目录必须是绝对路径。");
|
||||
}
|
||||
|
||||
let real: string;
|
||||
try {
|
||||
real = await fs.realpath(target);
|
||||
} catch {
|
||||
throw new HttpError(404, "dir_not_found", `目录不存在或不可访问:${target}。`);
|
||||
}
|
||||
const stat = await fs.stat(real);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new HttpError(400, "not_a_dir", "不是目录。");
|
||||
}
|
||||
|
||||
let dirents: import("node:fs").Dirent[] = [];
|
||||
try {
|
||||
dirents = await fs.readdir(real, { withFileTypes: true });
|
||||
} catch {
|
||||
// No read permission: return an empty list instead of an error, so the user can still navigate back up.
|
||||
dirents = [];
|
||||
}
|
||||
const entries = dirents
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => ({ name: d.name, path: path.join(real, d.name) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const parent = path.dirname(real);
|
||||
return c.json({
|
||||
path: real,
|
||||
parent: parent === real ? null : parent,
|
||||
entries,
|
||||
} satisfies DirListResponse);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* User-level server event stream: GET /api/events (SSE user channel).
|
||||
* Carries cross-Session notifications (reserved for automated tasks); sends a `hello` handshake event on connect.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { sseEndpoint } from "../sse.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
/** The user channel's key in ChannelHub. */
|
||||
export function userChannelKey(userId: string): string {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
|
||||
export function eventsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
const channel = deps.channels.get(userChannelKey(c.var.user.userId));
|
||||
return sseEndpoint(c, channel, { initialEvents: [{ type: "hello" }] });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Current-user routes: GET /api/me, PUT /api/me/password, GET|PUT /api/me/prefs.
|
||||
* ui_prefs is free-form JSON (theme / lastProjectId / credentialGuideSeen, etc.): GET reads
|
||||
* it whole, PUT shallow-merges (PATCH semantics) — several independent writers each write
|
||||
* their own fields without clobbering each other.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { MeResponse, PrefsResponse, UiPrefs } from "../../api/types.js";
|
||||
import { toUserInfo } from "../../auth/service.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { readJson, requireString } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.json({ user: toUserInfo(c.var.user) } satisfies MeResponse);
|
||||
});
|
||||
|
||||
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
|
||||
app.put("/password", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
|
||||
const newPassword = requireString(body, "newPassword", { label: "newPassword" });
|
||||
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
app.get("/prefs", (c) => {
|
||||
const raw = deps.prefsRepo.get(c.var.user.userId);
|
||||
let prefs: UiPrefs = {};
|
||||
if (raw !== null) {
|
||||
try {
|
||||
prefs = JSON.parse(raw) as UiPrefs;
|
||||
} catch {
|
||||
prefs = {}; // Corrupted prefs fall back to an empty object
|
||||
}
|
||||
}
|
||||
return c.json({ prefs } satisfies PrefsResponse);
|
||||
});
|
||||
|
||||
// PATCH semantics: the request body is **shallow-merged** into existing prefs, not a
|
||||
// full replace. prefs has several independent writers (lastProjectId /
|
||||
// credentialGuideSeen, etc., each writing their own field); a full replace would wipe
|
||||
// out each other's fields — e.g. writing lastProjectId when switching Projects would
|
||||
// clear credentialGuideSeen, breaking the "show onboarding once ever" guarantee.
|
||||
app.put("/prefs", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const raw = deps.prefsRepo.get(c.var.user.userId);
|
||||
let current: UiPrefs = {};
|
||||
if (raw !== null) {
|
||||
try {
|
||||
current = JSON.parse(raw) as UiPrefs;
|
||||
} catch {
|
||||
current = {}; // Corrupted prefs fall back to an empty object (consistent with GET).
|
||||
}
|
||||
}
|
||||
const merged = { ...current, ...(body as UiPrefs) };
|
||||
deps.prefsRepo.set(c.var.user.userId, JSON.stringify(merged));
|
||||
return c.json({ prefs: merged } satisfies PrefsResponse);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Member authorization routes:
|
||||
* GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId.
|
||||
* Reading requires access; adding/removing is owner-only (validated inside the service).
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { MemberAddResponse, MembersResponse } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { pathParam, readJson, requireString, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function membersRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
// Defensive id validation (FD-4).
|
||||
const members = deps.projectService.listMembers(
|
||||
c.var.user.userId,
|
||||
requireValidId(c, "projectId"),
|
||||
);
|
||||
return c.json({ members } satisfies MembersResponse);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const userId = requireString(body, "userId", { label: "userId" });
|
||||
const member = deps.projectService.addMember(
|
||||
c.var.user.userId,
|
||||
requireValidId(c, "projectId"),
|
||||
userId,
|
||||
);
|
||||
return c.json({ member } satisfies MemberAddResponse, 201);
|
||||
});
|
||||
|
||||
app.delete("/:userId", (c) => {
|
||||
deps.projectService.removeMember(
|
||||
c.var.user.userId,
|
||||
requireValidId(c, "projectId"),
|
||||
pathParam(c, "userId"),
|
||||
);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Model & credential config routes:
|
||||
* GET|PUT /api/projects/:p/models, POST /api/projects/:p/models/test (the model reference
|
||||
* `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding
|
||||
* issues). Any member can read (api_key is masked); only the owner can modify or test.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type {
|
||||
ModelRefDto,
|
||||
ModelsUpdateRequest,
|
||||
ModelTestRequest,
|
||||
ModelUpdateEntry,
|
||||
} from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
|
||||
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 } 对象。`);
|
||||
}
|
||||
const r = value as Record<string, unknown>;
|
||||
return {
|
||||
provider: requireString(r, "provider", { minLen: 1, maxLen: 64, label: `${label}.provider` }),
|
||||
modelId: requireString(r, "modelId", { minLen: 1, maxLen: 200, label: `${label}.modelId` }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 必须是数组。");
|
||||
const models: ModelUpdateEntry[] = body.models.map((item, i) => {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
throw badRequest(`models[${i}] 必须是对象。`);
|
||||
}
|
||||
const m = item as Record<string, unknown>;
|
||||
const entry: ModelUpdateEntry = {
|
||||
provider: requireString(m, "provider", {
|
||||
minLen: 1,
|
||||
maxLen: 64,
|
||||
label: `models[${i}].provider`,
|
||||
}),
|
||||
modelId: requireString(m, "modelId", {
|
||||
minLen: 1,
|
||||
maxLen: 200,
|
||||
label: `models[${i}].modelId`,
|
||||
}),
|
||||
};
|
||||
if (m.displayName !== undefined) {
|
||||
if (typeof m.displayName !== "string" || m.displayName.length > 100) {
|
||||
throw badRequest(`models[${i}].displayName 必须是长度不超过 100 的字符串。`);
|
||||
}
|
||||
if (m.displayName) entry.displayName = m.displayName;
|
||||
}
|
||||
// A key change (either the provider group or the upstream id) goes through renamedFrom's paired old reference; unknown fields are ignored.
|
||||
if (m.renamedFrom !== undefined) {
|
||||
entry.renamedFrom = parseRef(m.renamedFrom, `models[${i}].renamedFrom`);
|
||||
}
|
||||
if (m.contextWindow !== undefined) {
|
||||
if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
|
||||
throw badRequest(`models[${i}].contextWindow 必须是正数。`);
|
||||
}
|
||||
entry.contextWindow = m.contextWindow;
|
||||
}
|
||||
if (m.clientType !== undefined) {
|
||||
if (typeof m.clientType !== "string" || m.clientType.length > 64) {
|
||||
throw badRequest(`models[${i}].clientType 必须是长度不超过 64 的字符串。`);
|
||||
}
|
||||
// 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 必须是布尔值。`);
|
||||
}
|
||||
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 必须是对象。`);
|
||||
}
|
||||
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} 必须是非负数字。`);
|
||||
}
|
||||
}
|
||||
entry.pricing = {
|
||||
cacheRead: p.cacheRead as number,
|
||||
cacheWrite: p.cacheWrite as number,
|
||||
output: p.output as number,
|
||||
};
|
||||
}
|
||||
if (m.apiKey !== undefined) {
|
||||
if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
|
||||
throw badRequest(`models[${i}].apiKey 必须是非空字符串。`);
|
||||
}
|
||||
entry.apiKey = m.apiKey;
|
||||
}
|
||||
if (m.clearApiKey !== undefined) {
|
||||
if (typeof m.clearApiKey !== "boolean") {
|
||||
throw badRequest(`models[${i}].clearApiKey 必须是布尔值。`);
|
||||
}
|
||||
entry.clearApiKey = m.clearApiKey;
|
||||
}
|
||||
if (m.baseUrl !== undefined) {
|
||||
if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
|
||||
throw badRequest(`models[${i}].baseUrl 必须是字符串或 null。`);
|
||||
}
|
||||
entry.baseUrl = m.baseUrl as string | null;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
const req: ModelsUpdateRequest = { models };
|
||||
if (body.defaultModel !== undefined) {
|
||||
req.defaultModel = parseRef(body.defaultModel, "defaultModel");
|
||||
}
|
||||
if (body.visionModel !== undefined) {
|
||||
req.visionModel = parseRef(body.visionModel, "visionModel");
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation (FD-4).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
return c.json(await deps.projectConfigService.getModels(projectId));
|
||||
});
|
||||
|
||||
app.put("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
const req = parseModelsUpdate(await readJson(c));
|
||||
return c.json(await deps.projectConfigService.updateModels(projectId, req));
|
||||
});
|
||||
|
||||
// Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair
|
||||
// in the request body; sends one minimal request using that model's config. May include
|
||||
// not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet
|
||||
// (adding a custom model), everything is taken from the request body.
|
||||
app.post("/test", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
const body = await readJson(c);
|
||||
const req: ModelTestRequest = {
|
||||
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
|
||||
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
|
||||
};
|
||||
if (body.apiKey !== undefined) {
|
||||
if (typeof body.apiKey !== "string") throw badRequest("apiKey 必须是字符串。");
|
||||
if (body.apiKey) req.apiKey = body.apiKey;
|
||||
}
|
||||
if (body.clearApiKey !== undefined) {
|
||||
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey 必须是布尔值。");
|
||||
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。");
|
||||
}
|
||||
req.baseUrl = body.baseUrl ? body.baseUrl : null;
|
||||
}
|
||||
if (body.clientType !== undefined) {
|
||||
if (typeof body.clientType !== "string") throw badRequest("clientType 必须是字符串。");
|
||||
if (body.clientType) req.clientType = body.clientType;
|
||||
}
|
||||
return c.json(await deps.projectConfigService.testModel(projectId, req));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Project routes: GET|POST /api/projects, DELETE /api/projects/:p.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { ProjectCreateResponse, ProjectsResponse } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { optionalString, readJson, requireString, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
export function projectsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const projects = await deps.projectService.listProjects(c.var.user.userId);
|
||||
return c.json({ projects } satisfies ProjectsResponse);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await readJson(c);
|
||||
const projectId = requireString(body, "projectId", { label: "projectId" });
|
||||
const name = optionalString(body, "name", { minLen: 1, maxLen: 100, label: "name" });
|
||||
const project = await deps.projectService.createProject(c.var.user, projectId, name);
|
||||
return c.json({ project } satisfies ProjectCreateResponse, 201);
|
||||
});
|
||||
|
||||
app.delete("/:projectId", async (c) => {
|
||||
// Defensive id validation (FD-4): deleteProject constructs the project directory path and recursively deletes it.
|
||||
await deps.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Schedule routes:
|
||||
* GET|POST /api/projects/:p/agents/:a/schedules
|
||||
* GET|PUT|DELETE /api/projects/:p/agents/:a/schedules/:name (name is the file name)
|
||||
* Any member can read; only the owner can modify. The file is declarative intent:
|
||||
* POST/PUT fully replace the file, validation always goes through parseScheduleFile
|
||||
* (same rules as hand-edited files), and writes take effect immediately via reconciliation.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { isValidId } from "@prismshadow/penguin-core";
|
||||
import type { ScheduleItem, ScheduleStatus, SchedulesResponse } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import {
|
||||
badRequest,
|
||||
optionalString,
|
||||
readJson,
|
||||
requireString,
|
||||
requireValidId,
|
||||
} from "../validate.js";
|
||||
import type { ScheduleDefinition } from "../../runtime/schedule-file.js";
|
||||
import {
|
||||
latestSlotAt,
|
||||
nextSlotAfter,
|
||||
parseScheduleFile,
|
||||
slotInWindow,
|
||||
} from "../../runtime/schedule-file.js";
|
||||
import type { ScheduleStateRow } from "../../db/repos/schedules.js";
|
||||
import {
|
||||
deleteScheduleFile,
|
||||
readScheduleFile,
|
||||
serializeSchedule,
|
||||
validateScheduleModelRef,
|
||||
writeScheduleFile,
|
||||
} from "../../runtime/schedule-store.js";
|
||||
|
||||
/** Validate and shape the POST/PUT request body into file fields (semantic validation is left to parseScheduleFile). */
|
||||
function parseUpsertBody(body: Record<string, unknown>): {
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
startAt: string;
|
||||
period?: string;
|
||||
endAt?: string;
|
||||
sessionId?: string;
|
||||
workspace?: string;
|
||||
modelId?: string;
|
||||
provider?: string;
|
||||
} {
|
||||
if (typeof body.enabled !== "boolean") throw badRequest("enabled 必须是布尔值。");
|
||||
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 });
|
||||
const endAt = optionalString(body, "endAt", { minLen: 1, maxLen: 100 });
|
||||
const sessionId = optionalString(body, "sessionId", { minLen: 1, maxLen: 200 });
|
||||
const workspace = optionalString(body, "workspace", { minLen: 1, maxLen: 4096 });
|
||||
const modelId = optionalString(body, "modelId", { minLen: 1, maxLen: 200 });
|
||||
const provider = optionalString(body, "provider", { minLen: 1, maxLen: 64 });
|
||||
return {
|
||||
prompt,
|
||||
enabled: body.enabled,
|
||||
startAt,
|
||||
...(period !== undefined ? { period } : {}),
|
||||
...(endAt !== undefined ? { endAt } : {}),
|
||||
...(sessionId !== undefined ? { sessionId } : {}),
|
||||
...(workspace !== undefined ? { workspace } : {}),
|
||||
...(modelId !== undefined ? { modelId } : {}),
|
||||
...(provider !== undefined ? { provider } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Next scheduled fire time: none when disabled/invalid/done/missed; an undigested due slot counts as-is. */
|
||||
function nextFireAt(
|
||||
def: ScheduleDefinition,
|
||||
state: ScheduleStateRow,
|
||||
nowMs: number,
|
||||
): string | undefined {
|
||||
if (!def.enabled || state.invalidReason !== null) return undefined;
|
||||
if (def.periodMs === undefined && (state.firedOnce || state.missed)) return undefined;
|
||||
const due = latestSlotAt(def, nowMs);
|
||||
if (
|
||||
due !== null &&
|
||||
slotInWindow(def, due) &&
|
||||
(state.lastSlotMs === null || due > state.lastSlotMs)
|
||||
) {
|
||||
return new Date(due).toISOString();
|
||||
}
|
||||
const next = nextSlotAfter(def, nowMs);
|
||||
return next !== null ? new Date(next).toISOString() : undefined;
|
||||
}
|
||||
|
||||
/** Displayed status precedence: invalid > done/missed (one-shot) > expired > enabled flag. */
|
||||
function statusOf(def: ScheduleDefinition, state: ScheduleStateRow, nowMs: number): ScheduleStatus {
|
||||
if (state.invalidReason !== null) return "invalid";
|
||||
if (def.periodMs === undefined && state.firedOnce) return "done";
|
||||
if (def.periodMs === undefined && state.missed) return "missed";
|
||||
if (def.endAtMs !== undefined && nowMs > def.endAtMs) return "expired";
|
||||
return def.enabled ? "active" : "disabled";
|
||||
}
|
||||
|
||||
function toItem(
|
||||
def: ScheduleDefinition,
|
||||
state: ScheduleStateRow,
|
||||
queued: boolean,
|
||||
nowMs: number,
|
||||
): ScheduleItem {
|
||||
const next = nextFireAt(def, state, nowMs);
|
||||
return {
|
||||
name: def.name,
|
||||
prompt: def.prompt,
|
||||
enabled: def.enabled,
|
||||
startAt: def.startAt,
|
||||
...(def.period !== undefined ? { period: def.period } : {}),
|
||||
...(def.endAt !== undefined ? { endAt: def.endAt } : {}),
|
||||
...(def.sessionId !== undefined ? { sessionId: def.sessionId } : {}),
|
||||
...(def.workspace !== undefined ? { workspace: def.workspace } : {}),
|
||||
...(def.modelId !== undefined ? { modelId: def.modelId } : {}),
|
||||
...(def.provider !== undefined ? { provider: def.provider } : {}),
|
||||
status: statusOf(def, state, nowMs),
|
||||
...(state.invalidReason !== null ? { invalidReason: state.invalidReason } : {}),
|
||||
...(next !== undefined ? { nextFireAt: next } : {}),
|
||||
...(state.lastFiredAt !== null ? { lastFiredAt: state.lastFiredAt } : {}),
|
||||
queued,
|
||||
...(state.creatorUserId !== null ? { creatorUserId: state.creatorUserId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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("定时任务名非法。");
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
const { entries, invalid } = await deps.scheduler.listAgent(projectId, agentId);
|
||||
const nowMs = Date.now();
|
||||
const res: SchedulesResponse = {
|
||||
schedules: entries.map((e) => toItem(e.def, e.state, e.queued, nowMs)),
|
||||
invalidFiles: invalid,
|
||||
};
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
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}`);
|
||||
}
|
||||
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
|
||||
return c.json(await readItem(deps, projectId, agentId, name), 201);
|
||||
});
|
||||
|
||||
app.get("/:name", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const name = requireScheduleName(c.req.param("name"));
|
||||
const item = await readItem(deps, projectId, agentId, name);
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
app.put("/:name", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
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}`);
|
||||
}
|
||||
const body = await readJson(c);
|
||||
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
|
||||
return c.json(await readItem(deps, projectId, agentId, name));
|
||||
});
|
||||
|
||||
app.delete("/:name", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
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}`);
|
||||
deps.scheduler.dropEntry(projectId, agentId, name);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Write + register creator + reconcile immediately (API changes take effect right away). */
|
||||
async function upsert(
|
||||
deps: AppDeps,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
name: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const fields = parseUpsertBody(body);
|
||||
const raw = serializeSchedule(fields);
|
||||
const parsed = parseScheduleFile(name, raw);
|
||||
if (!parsed.ok) throw badRequest(`定时任务配置非法:${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}`);
|
||||
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({
|
||||
projectId,
|
||||
agentId,
|
||||
name,
|
||||
startAtMs: parsed.def.startAtMs,
|
||||
defHash: createHash("sha1").update(raw).digest("hex"),
|
||||
creatorUserId: userId,
|
||||
});
|
||||
await deps.scheduler.reconcileAgent(projectId, agentId);
|
||||
}
|
||||
|
||||
async function readItem(
|
||||
deps: AppDeps,
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
name: string,
|
||||
): Promise<ScheduleItem> {
|
||||
const { entries, invalid } = await deps.scheduler.listAgent(projectId, agentId);
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Session routes.
|
||||
*
|
||||
* Two entry groups:
|
||||
* - Agent-level: GET|POST /api/projects/:p/agents/:a/sessions (list including run state / create);
|
||||
* - Session-level: /api/sessions/:sessionId/* (no projectId; looks up project_id via the
|
||||
* sessions index, then goes through requireProjectAccess; 404 if the index has no such Session).
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
ApprovalMode,
|
||||
FilesStatResponse,
|
||||
MessagesResponse,
|
||||
ServerEvent,
|
||||
SessionCreateResponse,
|
||||
SessionResponse,
|
||||
SessionsResponse,
|
||||
TaskCreateResponse,
|
||||
} from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { SessionRow } from "../../db/repos/sessions.js";
|
||||
import { assertWorkspaceAllowed } from "../../services/workspace-guard.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { sseEndpoint } from "../sse.js";
|
||||
import {
|
||||
badRequest,
|
||||
optionalEnum,
|
||||
optionalString,
|
||||
paginationQuery,
|
||||
pathParam,
|
||||
positiveIntParam,
|
||||
readJson,
|
||||
requireEnum,
|
||||
requireValidId,
|
||||
} from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
import { MAX_UPLOAD_BYTES } from "../../services/workspace-files-service.js";
|
||||
|
||||
/** Max title length for manual renames: looser than the auto-generated 30-char limit, to accommodate users' own organizing conventions. */
|
||||
const SESSION_TITLE_MAX = 120;
|
||||
|
||||
/** Max path count and per-path length for a single files/stat check (message file-card candidates never exceed this scale). */
|
||||
const STAT_MAX_PATHS = 100;
|
||||
const STAT_MAX_PATH_LEN = 512;
|
||||
|
||||
const APPROVAL_MODES: readonly ApprovalMode[] = [
|
||||
"allow-all",
|
||||
"deny-all",
|
||||
"read-only",
|
||||
"always-ask",
|
||||
];
|
||||
|
||||
/** Validate Prompt input parts: text or image (data: / http(s) URL). */
|
||||
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
|
||||
const input = body.input;
|
||||
if (!Array.isArray(input) || input.length === 0) {
|
||||
throw badRequest("input 必须是至少包含一项的数组。");
|
||||
}
|
||||
return input.map((item, i) => {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
throw badRequest(`input[${i}] 必须是对象。`);
|
||||
}
|
||||
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 必须是非空字符串。`);
|
||||
}
|
||||
return userText(part.text);
|
||||
}
|
||||
if (part.type === "image_url") {
|
||||
const url = part.imageUrl;
|
||||
if (
|
||||
typeof url !== "string" ||
|
||||
!(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))
|
||||
) {
|
||||
throw badRequest(`input[${i}].imageUrl 仅支持 data: 或 http(s) URL。`);
|
||||
}
|
||||
return imageUrlMessage(url);
|
||||
}
|
||||
throw badRequest(`input[${i}].type 必须是 text / image_url 之一。`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent-level entry: /api/projects/:p/agents/:a/sessions. */
|
||||
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
const sessions = await deps.sessionService.listSessions(projectId, agentId);
|
||||
return c.json({ sessions } satisfies SessionsResponse);
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
const body = await readJson(c);
|
||||
const modelId = optionalString(body, "modelId", { minLen: 1, label: "modelId" });
|
||||
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:模型引用须成对给出。");
|
||||
}
|
||||
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
|
||||
let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" });
|
||||
if (workspace !== undefined) {
|
||||
// An explicitly specified Workspace must be an existing directory (never auto-created); reachability is determined by file permissions.
|
||||
workspace = await assertWorkspaceAllowed({ workspace });
|
||||
}
|
||||
const session = await deps.sessionService.createSession({
|
||||
projectId,
|
||||
agentId,
|
||||
...(modelId !== undefined ? { modelId } : {}),
|
||||
...(provider !== undefined ? { provider } : {}),
|
||||
...(workspace !== undefined ? { workspace } : {}),
|
||||
...(approvalMode !== undefined ? { approvalMode } : {}),
|
||||
});
|
||||
return c.json({ session } satisfies SessionCreateResponse, 201);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Session-level entry point: /api/sessions/:sessionId/*. */
|
||||
export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
/** Look up ownership and check access (404 if the index has no such Session, or access is denied — never leaking existence). */
|
||||
const resolveSession = (c: Context<AppEnv>): SessionRow => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null;
|
||||
if (!row) {
|
||||
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
|
||||
}
|
||||
try {
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
|
||||
} catch {
|
||||
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
app.get("/:sessionId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const hasTrace = await deps.sessionService.hasTrace(row);
|
||||
return c.json({ session: deps.sessionService.toInfo(row, hasTrace) } satisfies SessionResponse);
|
||||
});
|
||||
|
||||
app.patch("/:sessionId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
|
||||
const archivedRaw = (body as Record<string, unknown>).archived;
|
||||
const archived = typeof archivedRaw === "boolean" ? archivedRaw : undefined;
|
||||
const titleRaw = (body as Record<string, unknown>).title;
|
||||
let title: string | undefined;
|
||||
if (titleRaw !== undefined) {
|
||||
if (typeof titleRaw !== "string") {
|
||||
throw new HttpError(400, "invalid_title", "title 必须是字符串。");
|
||||
}
|
||||
title = titleRaw.trim();
|
||||
if (!title || title.length > SESSION_TITLE_MAX) {
|
||||
throw new HttpError(400, "invalid_title", `title 需为 1–${SESSION_TITLE_MAX} 个字符。`);
|
||||
}
|
||||
}
|
||||
if (approvalMode === undefined && archived === undefined && title === undefined) {
|
||||
throw new HttpError(400, "no_update", "缺少可更新字段(approvalMode / archived / title)。");
|
||||
}
|
||||
let updated: SessionRow = { ...row };
|
||||
if (title !== undefined) {
|
||||
// Manual renaming takes priority over auto-generation: TitleGenerator only persists a title while it's still NULL.
|
||||
deps.sessionsRepo.updateTitle(row.sessionId, title);
|
||||
updated = { ...updated, title };
|
||||
}
|
||||
if (approvalMode !== undefined) {
|
||||
// Takes effect immediately: a running approve callback re-reads the DB on every decision.
|
||||
deps.sessionsRepo.updateApprovalMode(row.sessionId, approvalMode);
|
||||
updated = { ...updated, approvalMode };
|
||||
}
|
||||
if (archived !== undefined) {
|
||||
const at = archived ? new Date().toISOString() : null;
|
||||
deps.sessionsRepo.setArchived(row.sessionId, at);
|
||||
updated = { ...updated, archivedAt: at };
|
||||
}
|
||||
const hasTrace = await deps.sessionService.hasTrace(updated);
|
||||
return c.json({
|
||||
session: deps.sessionService.toInfo(updated, hasTrace),
|
||||
} satisfies SessionResponse);
|
||||
});
|
||||
|
||||
app.delete("/:sessionId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
// Mark as being deleted and converge active runs (beginSessionDeletion): new
|
||||
// Tasks/compactions are always rejected with 409 during this window
|
||||
// (assertSessionNotDeleting), preventing the race where a new task recreates the
|
||||
// entry and Trace after abort but before the files are deleted, reviving an
|
||||
// already-deleted Session. Interrupt cleanup writes the Trace asynchronously, so we
|
||||
// wait for it to finish (≤5s cap) before deleting the files and index row; the
|
||||
// being-deleted marker is cleared once deletion finishes (success or failure).
|
||||
const runnings = deps.manager.beginSessionDeletion(row.sessionId);
|
||||
try {
|
||||
if (runnings.length > 0) {
|
||||
await Promise.race([
|
||||
Promise.allSettled(runnings).then(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5000).unref?.()),
|
||||
]);
|
||||
}
|
||||
await deps.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
|
||||
// The session-level scratchpad (model temp files + input images saved to disk for image-unsupported models) is deleted along with the session.
|
||||
await fs.rm(
|
||||
path.join(scratchpadDir(deps.config.root, row.projectId, row.agentId), row.sessionId),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
deps.sessionsRepo.deleteById(row.sessionId);
|
||||
} finally {
|
||||
deps.manager.endSessionDeletion(row.sessionId);
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
// Session scratchpad files (e.g. input images saved to disk for image-unsupported
|
||||
// models): read by filename, so the conversation UI can render a message's
|
||||
// "[attached image: <path>]" attachment line back into an image. Restricted to this
|
||||
// session's own scratchpad directory (the filename must not contain a path
|
||||
// separator, blocking traversal); filenames include a timestamp and are globally
|
||||
// unique, so the response is marked immutable and long-cacheable.
|
||||
app.get("/:sessionId/scratchpad/:fileName", async (c) => {
|
||||
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", "文件不存在。");
|
||||
}
|
||||
const filePath = path.join(
|
||||
scratchpadDir(deps.config.root, row.projectId, row.agentId),
|
||||
row.sessionId,
|
||||
fileName,
|
||||
);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await fs.readFile(filePath);
|
||||
} catch {
|
||||
throw new HttpError(404, "file_not_found", "文件不存在。");
|
||||
}
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mime = MIME_BY_EXT[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
|
||||
return c.body(new Uint8Array(bytes), 200, {
|
||||
"content-type": mime,
|
||||
"cache-control": "private, max-age=31536000, immutable",
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/:sessionId/messages", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const messages = await deps.traceService.readMessages(
|
||||
row.projectId,
|
||||
row.agentId,
|
||||
row.sessionId,
|
||||
);
|
||||
return c.json({ messages } satisfies MessagesResponse);
|
||||
});
|
||||
|
||||
app.get("/:sessionId/stream", (c) => {
|
||||
const row = resolveSession(c);
|
||||
const channel = deps.channels.get(row.sessionId);
|
||||
// FD-1: the first event of every new subscription (including reconnects and resync
|
||||
// rebuilds) is always a snapshot of the current running state — the frontend treats
|
||||
// this as authoritative, eliminating input-area lockup or premature Task closure
|
||||
// caused by a stale running/idle in the list; followed by replaying all still-pending
|
||||
// approval requests.
|
||||
const initialEvents: ServerEvent[] = [
|
||||
{ type: "task_state", state: deps.manager.statusOf(row.sessionId) },
|
||||
...deps.manager.pendingApprovals(row.sessionId).map((p) => ({
|
||||
type: "approval_request" as const,
|
||||
toolCall: p.toolCall,
|
||||
...(p.origin !== undefined ? { origin: p.origin } : {}),
|
||||
})),
|
||||
];
|
||||
return sseEndpoint(c, channel, { initialEvents });
|
||||
});
|
||||
|
||||
app.post("/:sessionId/tasks", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const input = parseTaskInput(await readJson(c));
|
||||
// 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal).
|
||||
const { sessionId } = await deps.manager.startTask(row.sessionId, input);
|
||||
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
|
||||
});
|
||||
|
||||
app.post("/:sessionId/approvals/:toolCallId", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
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", "该审批不存在或已被决定。");
|
||||
}
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
app.post("/:sessionId/abort", (c) => {
|
||||
const row = resolveSession(c);
|
||||
const aborted = deps.manager.abortTask(row.sessionId);
|
||||
// No Task in progress → 204 no-op; interrupt was triggered → 202 (wrap-up is completed by the SDK's "interrupt cleanup").
|
||||
return c.body(null, aborted ? 202 : 204);
|
||||
});
|
||||
|
||||
app.post("/:sessionId/compact", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const { sessionId } = await deps.manager.startCompact(row.sessionId);
|
||||
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
|
||||
});
|
||||
|
||||
// —— Workspace file browsing (Files tab) ——
|
||||
|
||||
app.get("/:sessionId/files", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const rel = c.req.query("path") ?? "";
|
||||
return c.json(await deps.workspaceFiles.list(row.workspace, rel));
|
||||
});
|
||||
|
||||
app.get("/:sessionId/files/content", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const rel = c.req.query("path") ?? "";
|
||||
const download = c.req.query("download") === "1";
|
||||
const { data, fileName, contentType, scriptable } = await deps.workspaceFiles.read(
|
||||
row.workspace,
|
||||
rel,
|
||||
);
|
||||
const disposition = download ? "attachment" : "inline";
|
||||
// Same-origin XSS defense: html/svg inline previews are always returned as plain
|
||||
// text (Workspace files may be Agent-generated and untrusted); downloads
|
||||
// (attachment) keep the real content type. Paired with nosniff to prevent MIME
|
||||
// sniffing from undoing this.
|
||||
const effectiveType = !download && scriptable ? "text/plain; charset=utf-8" : contentType;
|
||||
return new Response(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": effectiveType,
|
||||
"Content-Disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk existence check (message file cards list only files that actually exist):
|
||||
// path-confinement resolution shares the same logic as files/content
|
||||
// (WorkspaceFilesService.statExisting reuses resolveRead); out-of-bounds or
|
||||
// resolution failures count as not-existing, always 200 — existence itself is the
|
||||
// question being answered, and a 4xx would only leak confinement details.
|
||||
app.post("/:sessionId/files/stat", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const body = await readJson(c);
|
||||
const paths = body.paths;
|
||||
if (
|
||||
!Array.isArray(paths) ||
|
||||
paths.length > STAT_MAX_PATHS ||
|
||||
!paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN)
|
||||
) {
|
||||
throw badRequest(
|
||||
`paths 必须是字符串数组(≤${STAT_MAX_PATHS} 项,每项 ≤${STAT_MAX_PATH_LEN} 字符)。`,
|
||||
);
|
||||
}
|
||||
const existing = await deps.workspaceFiles.statExisting(row.workspace, paths as string[]);
|
||||
return c.json({ existing } satisfies FilesStatResponse);
|
||||
});
|
||||
|
||||
app.put("/:sessionId/files/content", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const rel = c.req.query("path") ?? "";
|
||||
const body = await readJson(c);
|
||||
if (typeof body.dataBase64 !== "string") {
|
||||
throw badRequest("dataBase64 必须是 base64 字符串。");
|
||||
}
|
||||
const data = Buffer.from(body.dataBase64, "base64");
|
||||
if (data.length > MAX_UPLOAD_BYTES) {
|
||||
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。");
|
||||
}
|
||||
await deps.workspaceFiles.write(row.workspace, rel, data);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
app.get("/:sessionId/traces", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const files = await deps.traceService.listTraceFiles(row.projectId, row.agentId, row.sessionId);
|
||||
return c.json({ files });
|
||||
});
|
||||
|
||||
app.get("/:sessionId/traces/:index", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const index = positiveIntParam(c, "index");
|
||||
const { offset, limit } = paginationQuery(c);
|
||||
return c.json(
|
||||
await deps.traceService.readEvents(
|
||||
row.projectId,
|
||||
row.agentId,
|
||||
row.sessionId,
|
||||
index,
|
||||
offset,
|
||||
limit,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/:sessionId/traces/:index/analysis", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const index = positiveIntParam(c, "index");
|
||||
return c.json(
|
||||
await deps.traceService.analyze(row.projectId, row.agentId, row.sessionId, index),
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Skill library & Agent-installed-Skills routes:
|
||||
* GET /api/skills # library groups & metadata (any logged-in user)
|
||||
* GET|POST /api/projects/:p/agents/:a/skills # installed list / install from library (any member)
|
||||
* DELETE /api/projects/:p/agents/:a/skills/:name # uninstall (any member)
|
||||
* Installing writes the library's SKILL.md verbatim to agent_state/skills/<name>/;
|
||||
* reinstalling overwrites with the library content (i.e. an update). The scope is small
|
||||
* enough to skip a service layer — routes call core's disk-writing functions directly.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
installSkill,
|
||||
listInstalledSkills,
|
||||
removeSkill,
|
||||
skillsDir,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import { librarySkill, loadSkillGroups } from "@prismshadow/penguin-skills";
|
||||
import type { LibrarySkill, SkillMetadata } from "@prismshadow/penguin-skills";
|
||||
import type {
|
||||
AgentSkillsResponse,
|
||||
SkillLibraryResponse,
|
||||
SkillMetadataItem,
|
||||
} from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { badRequest, readJson, requireValidId } from "../validate.js";
|
||||
|
||||
/**
|
||||
* Strips the content off a LibrarySkill: the API only sends metadata; the full body is
|
||||
* written to disk on install and read by the model on demand. The optional short
|
||||
* description (shortDescription(Zh)) and custom icon (icon.svg source) are conditionally
|
||||
* passed through — both the library side (LibrarySkill) and the installed side (core
|
||||
* InstalledSkill) carry these fields.
|
||||
*/
|
||||
function toMetadataItem(skill: SkillMetadata & { icon?: string }): SkillMetadataItem {
|
||||
return {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...(skill.shortDescription !== undefined ? { shortDescription: skill.shortDescription } : {}),
|
||||
...(skill.shortDescriptionZh !== undefined
|
||||
? { shortDescriptionZh: skill.shortDescriptionZh }
|
||||
: {}),
|
||||
...(skill.icon !== undefined ? { icon: skill.icon } : {}),
|
||||
version: skill.version,
|
||||
updated: skill.updated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Library listing response: the files are the source of truth — read and parse the library directory fresh on every request (files are small, requests infrequent, no caching needed). */
|
||||
function libraryResponse(): SkillLibraryResponse {
|
||||
return {
|
||||
groups: loadSkillGroups().map((group) => ({
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
...(group.titleZh !== undefined ? { titleZh: group.titleZh } : {}),
|
||||
skills: group.skills.map(toMetadataItem),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 必须是非空数组。");
|
||||
}
|
||||
return body.names.map((v, i) => {
|
||||
if (typeof v !== "string" || v.length === 0) {
|
||||
throw badRequest(`names[${i}] 必须是非空字符串。`);
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/skills: Skill library groups & metadata (any logged-in user; no Project check). */
|
||||
export function skillLibraryRoutes(): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.get("/", (c) => c.json(libraryResponse()));
|
||||
return app;
|
||||
}
|
||||
|
||||
/** /api/projects/:p/agents/:a/skills: read, install, and uninstall are all Project-member operations. */
|
||||
export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
const listResponse = async (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
): Promise<AgentSkillsResponse> => ({
|
||||
skills: (await listInstalledSkills(deps.config.root, projectId, agentId)).map(toMetadataItem),
|
||||
});
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation happens before any path construction (FD-4: prevents path traversal for cross-Project privilege escalation).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
return c.json(await listResponse(projectId, agentId));
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
await deps.agentConfigService.requireExists(projectId, agentId);
|
||||
const names = parseInstallNames(await readJson(c));
|
||||
// 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}`);
|
||||
return skill;
|
||||
});
|
||||
for (const skill of skills) {
|
||||
await installSkill(deps.config.root, projectId, agentId, skill);
|
||||
}
|
||||
return c.json(await listResponse(projectId, agentId), 201);
|
||||
});
|
||||
|
||||
app.delete("/:name", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
const name = requireValidId(c, "name");
|
||||
// Installed-check uses the same criterion as listInstalledSkills: skills/<name>/SKILL.md exists.
|
||||
const file = path.join(skillsDir(deps.config.root, projectId, agentId), name, "SKILL.md");
|
||||
try {
|
||||
await fs.access(file);
|
||||
} catch {
|
||||
throw new HttpError(404, "not_found", `Skill 未安装:${name}`);
|
||||
}
|
||||
await removeSkill(deps.config.root, projectId, agentId, name);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Usage statistics routes:
|
||||
* GET /api/projects/:p/usage?from&to&groupBy&agentId&provider&modelId
|
||||
* (model filter is paired: provider and modelId are given together).
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { UsageGroupBy } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { badRequest, optionalDateParam, requireValidId } from "../validate.js";
|
||||
import type { AppDeps } from "../../app.js";
|
||||
|
||||
const GROUP_BYS: readonly UsageGroupBy[] = ["date", "agent", "model", "session"];
|
||||
|
||||
export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation (FD-4).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
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(" / ")} 之一。`);
|
||||
}
|
||||
const from = optionalDateParam(c.req.query("from"), "from");
|
||||
const to = optionalDateParam(c.req.query("to"), "to");
|
||||
const agentId = c.req.query("agentId");
|
||||
const provider = c.req.query("provider");
|
||||
const modelId = c.req.query("modelId");
|
||||
return c.json(
|
||||
await deps.usageService.query(projectId, {
|
||||
groupBy: groupByRaw as UsageGroupBy,
|
||||
// Unattributed errors (login failures, process crashes, etc. with no Project
|
||||
// context) are visible only to admins: requireProjectAccess only guarantees
|
||||
// "is a member of this Project" — a regular member seeing another tenant's errors
|
||||
// would be a cross-tenant information leak.
|
||||
includeGlobalErrors: c.var.user.isAdmin,
|
||||
...(from !== undefined ? { from } : {}),
|
||||
...(to !== undefined ? { to } : {}),
|
||||
...(agentId !== undefined && agentId !== "" ? { agentId } : {}),
|
||||
...(provider !== undefined && provider !== "" ? { provider } : {}),
|
||||
...(modelId !== undefined && modelId !== "" ? { modelId } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Vault environment variable routes:
|
||||
* GET|PUT /api/projects/:p/agents/:a/vault (Agent-level, agent_state/.vault.toml).
|
||||
* Any member can read (values masked); only the owner can modify; 404 if the Agent doesn't exist.
|
||||
*/
|
||||
import { Hono } from "hono";
|
||||
import type { VaultEntryUpdate, VaultUpdateRequest } from "../../api/types.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
|
||||
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 必须是数组。");
|
||||
const entries: VaultEntryUpdate[] = body.entries.map((item, i) => {
|
||||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||||
throw badRequest(`entries[${i}] 必须是对象。`);
|
||||
}
|
||||
const e = item as Record<string, unknown>;
|
||||
const entry: VaultEntryUpdate = {
|
||||
key: requireString(e, "key", { minLen: 1, maxLen: 200, label: `entries[${i}].key` }),
|
||||
};
|
||||
if (e.value !== undefined) {
|
||||
if (typeof e.value !== "string" || e.value.length === 0) {
|
||||
throw badRequest(`entries[${i}].value 必须是非空字符串。`);
|
||||
}
|
||||
entry.value = e.value;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return { entries };
|
||||
}
|
||||
|
||||
export function vaultRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Defensive id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
|
||||
return c.json(await deps.agentConfigService.getVault(projectId, agentId));
|
||||
});
|
||||
|
||||
app.put("/", async (c) => {
|
||||
const projectId = requireValidId(c, "projectId");
|
||||
const agentId = requireValidId(c, "agentId");
|
||||
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
|
||||
const req = parseVaultUpdate(await readJson(c));
|
||||
const res = await deps.agentConfigService.updateVault(projectId, agentId, req);
|
||||
// Effective-value semantics: no hot update — an already-built runtime is neither
|
||||
// evicted nor reloaded; the new value only applies to Sessions created or resumed
|
||||
// afterward.
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Convergent wait for delete routes (currently used by agents DELETE; sessions DELETE
|
||||
* uses the same inline pattern and could later be unified onto this): waits for aborted
|
||||
* runs to wind down, up to ms milliseconds; returns whether all of them settled within
|
||||
* the window. The timer is unref'd so it never blocks process exit.
|
||||
*/
|
||||
export async function settleWithin(promises: Promise<unknown>[], ms: number): Promise<boolean> {
|
||||
if (promises.length === 0) return true;
|
||||
return Promise.race([
|
||||
Promise.allSettled(promises).then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), ms).unref?.()),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* SSE endpoint adapter:
|
||||
* writes the runtime/channel event stream as an SSE response, shared by both the Session
|
||||
* channel and the user channel.
|
||||
*
|
||||
* - Response headers: text/event-stream, no-cache, `X-Accel-Buffering: no` (disables
|
||||
* buffering on reverse proxies);
|
||||
* - Heartbeat: writes a `: ping` comment line every 20s; the connection is torn down on write failure;
|
||||
* - Replay protocol: a fresh subscription without Last-Event-ID does not replay the buffer
|
||||
* (history is served by the messages endpoint) — it only sends the initial events the
|
||||
* caller supplied (pending approvals / hello). With a Last-Event-ID that hits the buffer,
|
||||
* replay resumes from there; on a miss, `resync_required` is sent first, then the
|
||||
* connection continues.
|
||||
* Docs: /docs/server-api § "Delivery Guarantees".
|
||||
*/
|
||||
import type { Context } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { ServerEvent } from "../api/types.js";
|
||||
import type { Channel, ChannelEvent, ChannelListener } from "../runtime/channel.js";
|
||||
|
||||
const HEARTBEAT_MS = 20_000;
|
||||
|
||||
export interface SseEndpointOptions {
|
||||
/** Initial server events to send privately after the subscription is established (in order): pending-approval replay / user channel hello. */
|
||||
initialEvents?: ServerEvent[];
|
||||
}
|
||||
|
||||
/** Stream out a Channel as an SSE response. */
|
||||
export function sseEndpoint(c: Context, channel: Channel, opts: SseEndpointOptions = {}): Response {
|
||||
const lastEventIdHeader = c.req.header("Last-Event-ID");
|
||||
c.header("X-Accel-Buffering", "no");
|
||||
c.header("Cache-Control", "no-cache");
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
let closed = false;
|
||||
let finish: () => void = () => {};
|
||||
const done = new Promise<void>((resolve) => {
|
||||
finish = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
|
||||
// Write serialization: SSE events must be written fully and in order; any write failure tears down the connection.
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
const enqueue = (write: () => Promise<unknown>): void => {
|
||||
chain = chain
|
||||
.then(async () => {
|
||||
if (!closed) await write();
|
||||
})
|
||||
.catch(() => finish());
|
||||
};
|
||||
const listener: ChannelListener = (evt: ChannelEvent) => {
|
||||
enqueue(() =>
|
||||
stream.writeSSE({
|
||||
data: evt.data,
|
||||
// Event id is an opaque string generated by the channel (`<epoch>-<seq>`), passed through as-is.
|
||||
id: evt.id,
|
||||
...(evt.event !== undefined ? { event: evt.event } : {}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const unsubscribe = channel.subscribe(listener);
|
||||
// Subscribe first (to avoid dropping events in a race with broadcasts), then replay
|
||||
// synchronously — event order: buffered replay (or resync_required) -> initial events
|
||||
// (task_state snapshot / pending approvals / hello) -> live stream.
|
||||
if (lastEventIdHeader !== undefined) {
|
||||
const replay = channel.replayAfter(lastEventIdHeader);
|
||||
if (!replay.hit) {
|
||||
const resync: ServerEvent = { type: "resync_required" };
|
||||
channel.sendTo(listener, resync, "server_event");
|
||||
} else {
|
||||
for (const evt of replay.events) listener(evt);
|
||||
}
|
||||
}
|
||||
for (const event of opts.initialEvents ?? []) {
|
||||
channel.sendTo(listener, event, "server_event");
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
enqueue(() => stream.write(": ping\n\n"));
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
stream.onAbort(() => finish());
|
||||
try {
|
||||
await done;
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Hand-rolled request body validation helpers (fields follow TypeScript types;
|
||||
* this adds a runtime safety net).
|
||||
*
|
||||
* No validation library: each helper checks one basic shape, throwing a 400 HttpError on failure.
|
||||
*/
|
||||
import type { Context } from "hono";
|
||||
import { isValidId } from "@prismshadow/penguin-core";
|
||||
import { HttpError } from "./errors.js";
|
||||
|
||||
export function badRequest(message: string): HttpError {
|
||||
return new HttpError(400, "bad_request", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a path parameter (under sub-route mounting, hono infers string | undefined; the
|
||||
* route guarantees presence at runtime — treat a defensive missing value as 404).
|
||||
*/
|
||||
export function pathParam(c: Context, name: string): string {
|
||||
const v = c.req.param(name);
|
||||
if (v === undefined || v === "") {
|
||||
throw new HttpError(404, "not_found", "路径参数缺失。");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a path parameter and validate the id (alphanumeric, underscore, and hyphen
|
||||
* only, to prevent path traversal). Hono decodes URL-encoded `%2F` into a single path
|
||||
* parameter; an id containing `/` or `..` passed straight into path construction could
|
||||
* escape the resource directory (cross-Project privilege escalation). So validate right
|
||||
* after reading the value — any invalid id is rejected with 404 (not leaking resource
|
||||
* existence), before any service-layer or path-construction code runs.
|
||||
*/
|
||||
export function requireValidId(c: Context, name: string): string {
|
||||
const v = pathParam(c, name);
|
||||
if (!isValidId(v)) {
|
||||
throw new HttpError(404, "not_found", "资源不存在或无权访问。");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 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} 必须是正整数。`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Parse Trace pagination query params: offset >= 0 (default 0), limit 1-1000 (default 200). */
|
||||
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(limit) || limit < 1 || limit > 1000) {
|
||||
throw badRequest("limit 必须是 1~1000 的整数。");
|
||||
}
|
||||
return { offset, limit };
|
||||
}
|
||||
|
||||
/** Read the JSON request body (parse failure / non-object -> 400). */
|
||||
export async function readJson(c: Context): Promise<Record<string, unknown>> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
throw badRequest("请求体必须是合法 JSON。");
|
||||
}
|
||||
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
||||
throw badRequest("请求体必须是 JSON 对象。");
|
||||
}
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StringRule {
|
||||
minLen?: number;
|
||||
maxLen?: number;
|
||||
pattern?: RegExp;
|
||||
/** Display name for the field in error messages (defaults to key). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function requireString(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
rule: StringRule = {},
|
||||
): string {
|
||||
const v = obj[key];
|
||||
const label = rule.label ?? key;
|
||||
if (typeof v !== "string") throw badRequest(`${label} 必须是字符串。`);
|
||||
if (rule.minLen !== undefined && v.length < rule.minLen) {
|
||||
throw badRequest(`${label} 长度至少 ${rule.minLen} 个字符。`);
|
||||
}
|
||||
if (rule.maxLen !== undefined && v.length > rule.maxLen) {
|
||||
throw badRequest(`${label} 长度不能超过 ${rule.maxLen} 个字符。`);
|
||||
}
|
||||
if (rule.pattern !== undefined && !rule.pattern.test(v)) {
|
||||
throw badRequest(`${label} 格式不合法。`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function optionalString(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
rule: StringRule = {},
|
||||
): string | undefined {
|
||||
if (obj[key] === undefined) return undefined;
|
||||
return requireString(obj, key, rule);
|
||||
}
|
||||
|
||||
export function requireEnum<T extends string>(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
values: readonly T[],
|
||||
label = key,
|
||||
): T {
|
||||
const v = obj[key];
|
||||
if (typeof v !== "string" || !(values as readonly string[]).includes(v)) {
|
||||
throw badRequest(`${label} 必须是 ${values.join(" / ")} 之一。`);
|
||||
}
|
||||
return v as T;
|
||||
}
|
||||
|
||||
export function optionalEnum<T extends string>(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
values: readonly T[],
|
||||
label = key,
|
||||
): T | undefined {
|
||||
if (obj[key] === undefined) return undefined;
|
||||
return requireEnum(obj, key, values, label);
|
||||
}
|
||||
|
||||
export interface NumberRule {
|
||||
/** Require positive or -1 (Agent runtime parameter convention: >0 active, -1 disabled). */
|
||||
positiveOrMinusOne?: boolean;
|
||||
/** Require non-negative. */
|
||||
nonNegative?: boolean;
|
||||
/** Require integer. */
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function optionalNumber(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
rule: NumberRule = {},
|
||||
): number | undefined {
|
||||
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 (rule.positiveOrMinusOne && !(v > 0 || v === -1)) {
|
||||
throw badRequest(`${label} 必须大于 0 或为 -1。`);
|
||||
}
|
||||
if (rule.nonNegative && v < 0) throw badRequest(`${label} 不能为负数。`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Validate a yyyy-mm-dd query parameter (defaults to undefined). */
|
||||
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 格式。`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user