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:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
/**
* Hono app assembly: middleware + route mounting + static hosting +
* error handling.
*
* `createApp(deps)` is pure assembly (does not listen on a port): tests inject requests via
* `app.request()`; `buildAppDeps(config)` assembles all services from config (test doubles
* like SessionLoader can be injected). The startup entry point is in index.ts.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { Hono } from "hono";
import type { Context } from "hono";
import type { DatabaseSync } from "node:sqlite";
import type { ServerConfig } from "./config.js";
import { openDatabase } from "./db/database.js";
import { AgentsRepo } from "./db/repos/agents.js";
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
import { ErrorsRepo } from "./db/repos/errors.js";
import { MembersRepo } from "./db/repos/members.js";
import { ProjectsRepo } from "./db/repos/projects.js";
import { SchedulesRepo } from "./db/repos/schedules.js";
import { SessionsRepo } from "./db/repos/sessions.js";
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
import { UsageRepo } from "./db/repos/usage.js";
import { UsersRepo } from "./db/repos/users.js";
import type { UserRow } from "./db/repos/users.js";
import { authMiddleware, jsonOnlyWrites } from "./auth/middleware.js";
import type { AppEnv } from "./auth/middleware.js";
import { AuthService } from "./auth/service.js";
import { handleError, HttpError, errorBody } from "./http/errors.js";
import { adminUsersRoutes } from "./http/routes/admin.js";
import { authRoutes } from "./http/routes/auth.js";
import { meRoutes } from "./http/routes/me.js";
import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
import { projectsRoutes } from "./http/routes/projects.js";
import { membersRoutes } from "./http/routes/members.js";
import { modelsRoutes } from "./http/routes/models.js";
import { vaultRoutes } from "./http/routes/vault.js";
import { scheduleRoutes } from "./http/routes/schedules.js";
import { benchmarksRoutes } from "./http/routes/benchmarks.js";
import { agentSkillsRoutes, skillLibraryRoutes } from "./http/routes/skills.js";
import { agentTransferRoutes } from "./http/routes/agent-transfer.js";
import { agentsRoutes } from "./http/routes/agents.js";
import { dirsRoutes } from "./http/routes/dirs.js";
import { agentConfigRoutes } from "./http/routes/agent-config.js";
import { agentTracesRoutes } from "./http/routes/agent-traces.js";
import { usageRoutes } from "./http/routes/usage.js";
import { agentSessionsRoutes, sessionsRoutes } from "./http/routes/sessions.js";
import { ChannelHub } from "./runtime/channel.js";
import { ErrorRecorder } from "./runtime/error-recorder.js";
import { createCoreSessionLoader, SessionManager } from "./runtime/session-manager.js";
import type { SessionLoader } from "./runtime/session-manager.js";
import { Scheduler } from "./runtime/scheduler.js";
import { TitleGenerator } from "./runtime/title-generator.js";
import type { TitleNotifier } from "./runtime/title-generator.js";
import { UsageRecorder } from "./runtime/usage-recorder.js";
import { AdminService } from "./services/admin-service.js";
import { AgentConfigService } from "./services/agent-config-service.js";
import { AgentService } from "./services/agent-service.js";
import { BenchmarkService } from "./services/benchmark-service.js";
import { SnapshotService } from "./services/snapshot-service.js";
import { ProjectConfigService } from "./services/project-config-service.js";
import { ProjectService } from "./services/project-service.js";
import { SessionService } from "./services/session-service.js";
import { TraceService } from "./services/trace-service.js";
import { UsageService } from "./services/usage-service.js";
import { WorkspaceFilesService } from "./services/workspace-files-service.js";
/** Request body size limit (tasks may carry data: images): 20MB. */
const MAX_BODY_BYTES = 20 * 1024 * 1024;
export interface AppDeps {
config: ServerConfig;
db: DatabaseSync;
sessionsRepo: SessionsRepo;
prefsRepo: UiPrefsRepo;
authService: AuthService;
adminService: AdminService;
projectService: ProjectService;
projectConfigService: ProjectConfigService;
agentService: AgentService;
agentConfigService: AgentConfigService;
sessionService: SessionService;
traceService: TraceService;
usageService: UsageService;
workspaceFiles: WorkspaceFilesService;
benchmarks: BenchmarkService;
snapshots: SnapshotService;
schedulesRepo: SchedulesRepo;
scheduler: Scheduler;
channels: ChannelHub;
manager: SessionManager;
/** Error persistence (shared by app.onError and various background capture points; the process-level fallback is in index.ts). */
errors: ErrorRecorder;
/** Request log output (minimal one-liner); tests inject a noop. */
log: (line: string) => void;
}
export interface BuildDepsOverrides {
/** Test double: session-manager's underlying loader (avoids the real LLM/SDK path). */
loader?: SessionLoader;
/** Test double: Session title generator (avoids real LLM requests). */
titles?: TitleNotifier;
log?: (line: string) => void;
now?: () => Date;
}
/** Assemble all services from config (shared by production and tests; tests pass dbPath=":memory:" and a temp root). */
export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides = {}): AppDeps {
const db = openDatabase(config.dbPath);
const log = overrides.log ?? ((line: string) => console.log(line));
const usersRepo = new UsersRepo(db);
const authSessionsRepo = new AuthSessionsRepo(db);
const projectsRepo = new ProjectsRepo(db);
const membersRepo = new MembersRepo(db);
const agentsRepo = new AgentsRepo(db);
const sessionsRepo = new SessionsRepo(db);
const usageRepo = new UsageRepo(db);
const errorsRepo = new ErrorsRepo(db);
const prefsRepo = new UiPrefsRepo(db);
const schedulesRepo = new SchedulesRepo(db);
const projectConfigService = new ProjectConfigService(config.root);
const agentConfigService = new AgentConfigService(config.root);
const agentService = new AgentService(config.root, agentsRepo, agentConfigService);
const traceService = new TraceService(config.root);
const workspaceFiles = new WorkspaceFilesService();
const benchmarks = new BenchmarkService(config.root);
const snapshots = new SnapshotService(config.root);
const usageService = new UsageService(
usageRepo,
errorsRepo,
(projectId, provider, modelId) => projectConfigService.getPricing(projectId, provider, modelId),
overrides.now ?? (() => new Date()),
);
// Channel idle reclamation skips active Sessions (running/compacting can go a long time
// without a publish, e.g. while waiting for approval).
// manager is created after channels: use a lazy predicate (managerRef is assigned by the
// time the sweep timer fires).
let managerRef: SessionManager | undefined;
const channels = new ChannelHub({
isActive: (key) => managerRef !== undefined && managerRef.statusOf(key) !== "idle",
});
const recorder = new UsageRecorder(usageRepo, overrides.now ?? (() => new Date()));
const errors = new ErrorRecorder(errorsRepo, overrides.now ?? (() => new Date()));
const titles =
overrides.titles ??
new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log });
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
loader: overrides.loader ?? createCoreSessionLoader(config.root),
recorder,
errors,
titles,
log,
});
managerRef = manager;
const projectService = new ProjectService({
root: config.root,
users: usersRepo,
projects: projectsRepo,
members: membersRepo,
agents: agentsRepo,
sessions: sessionsRepo,
usage: usageRepo,
errors: errorsRepo,
schedules: schedulesRepo,
projectConfig: projectConfigService,
manager,
});
const authService = new AuthService({
users: usersRepo,
authSessions: authSessionsRepo,
provisionInitialProject: (user, isAdmin) =>
projectService.provisionInitialProject(user, isAdmin),
sessionTtlMs: config.authSessionTtlMs,
sessionRenewMs: config.authSessionRenewMs,
...(overrides.now ? { now: overrides.now } : {}),
});
const adminService = new AdminService({
users: usersRepo,
authSessions: authSessionsRepo,
projects: projectsRepo,
projectService,
...(overrides.now ? { now: overrides.now } : {}),
});
const sessionService = new SessionService({
root: config.root,
sessions: sessionsRepo,
manager,
projectConfig: projectConfigService,
});
// Schedule scheduler: active only while the server is running. Only
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
const scheduler = new Scheduler({
root: config.root,
repo: schedulesRepo,
projects: projectsRepo,
sessions: sessionsRepo,
runner: manager,
sessionCreator: sessionService,
errors,
notify: (userId, event) => {
channels.get(userChannelKey(userId)).publish(event, "server_event");
},
...(overrides.now ? { now: () => overrides.now!().getTime() } : {}),
});
return {
config,
db,
sessionsRepo,
prefsRepo,
authService,
adminService,
projectService,
projectConfigService,
agentService,
agentConfigService,
sessionService,
traceService,
usageService,
workspaceFiles,
benchmarks,
snapshots,
schedulesRepo,
scheduler,
channels,
manager,
errors,
log,
};
}
/** Assembles the Hono app (does not listen on a port). */
export function createApp(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
// Error recording is layered in a lambda wrapping onError: handleError stays a
// pure function with unchanged behavior (HttpError is mapped as-is, unknown
// exceptions are logged with a stack trace and collapsed to 500), and recording
// to the DB is just a side-effect layered on top.
app.onError((err, c) => {
const projectId = attributedProjectId(c, deps);
deps.errors.record({
source: "http",
err,
...(projectId !== undefined ? { ctx: { projectId } } : {}),
});
return handleError(err, c);
});
app.notFound((c) => c.json(errorBody("not_found", "接口不存在。"), 404));
// Request logging: a minimal one-liner (method path status ms).
app.use("*", async (c, next) => {
const start = performance.now();
await next();
const ms = Math.round(performance.now() - start);
deps.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`);
});
// API common defenses: request body size cap (20MB) and write-request Content-Type (one of the CSRF MVP defenses).
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 上限。");
}
await next();
});
app.use("/api/*", jsonOnlyWrites);
// Public routes (no login required).
app.route("/api/auth", authRoutes(deps));
// Protected routes: cookie -> auth_session -> user.
const auth = authMiddleware(deps.authService);
app.use("/api/*", auth);
app.route("/api/me", meRoutes(deps));
app.route("/api/admin/users", adminUsersRoutes(deps));
app.route("/api/events", eventsRoutes(deps));
// Skill library listing: readable once logged in, not nested under a Project prefix.
app.route("/api/skills", skillLibraryRoutes());
app.route("/api/projects", projectsRoutes(deps));
app.route("/api/projects/:projectId/members", membersRoutes(deps));
app.route("/api/projects/:projectId/models", modelsRoutes(deps));
app.route("/api/projects/:projectId/agents", agentsRoutes(deps));
app.route("/api/projects/:projectId/dirs", dirsRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/vault", vaultRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/schedules", scheduleRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/benchmarks", benchmarksRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/skills", agentSkillsRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId", agentTransferRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/traces", agentTracesRoutes(deps));
app.route("/api/projects/:projectId/agents/:agentId/sessions", agentSessionsRoutes(deps));
app.route("/api/projects/:projectId/usage", usageRoutes(deps));
app.route("/api/sessions", sessionsRoutes(deps));
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
if (fs.existsSync(deps.config.webDist)) {
registerStaticRoutes(app, deps.config.webDist);
}
return app;
}
/**
* The Project an error is attributed to: only
* attributed when the URL has a `:projectId` **and** the requester genuinely has
* access to that Project; otherwise recorded as unattributed (`project_id IS
* NULL`, visible only to admins).
*
* onError also has to handle requests that **haven't passed permission checks
* yet** — a 401 from being logged out, a 404 from not being a member, both get
* recorded here. Attributing directly from the URL parameter would let anyone
* (not necessarily a member of that Project, or even logged in) pick a projectId
* and hammer it repeatedly to pollute another user's Project with error stats.
* Traces that can't be attributed simply fall into the admin view (unattributed
* errors are only visible to admins by design anyway), which is exactly where
* unauthorized probing belongs.
*
* Two defenses here, because this code runs on the error-handling path:
* - `c.var.user`'s static type is non-null, but authMiddleware never sets it
* **before** throwing the 401 when logged out, so at runtime it may actually be
* undefined — it can only be read safely, never destructured directly.
* - Exceptions are swallowed entirely: throwing here would break onError itself
* (possibly recursively); any judgment failure falls back to unattributed.
*/
function attributedProjectId(c: Context<AppEnv>, deps: AppDeps): string | undefined {
try {
const projectId = c.req.param("projectId");
if (projectId === undefined) return undefined;
const user = c.get("user") as UserRow | undefined;
if (user === undefined) return undefined;
return deps.projectService.canAccess(user.userId, projectId) ? projectId : undefined;
} catch {
return undefined;
}
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".ico": "image/x-icon",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
".woff2": "font/woff2",
};
/** Minimal static file server (avoiding an extra dependency): path traversal protection + SPA fallback. */
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);
}
const rel = reqPath.replace(/^\/+/, "");
const resolved = path.resolve(webDist, rel === "" ? "index.html" : rel);
// Guard against path traversal: once resolved, it must still be inside webDist.
const base = path.resolve(webDist);
const target =
resolved === base || resolved.startsWith(base + path.sep)
? resolved
: path.join(base, "index.html");
let file = target;
try {
const stat = await fsp.stat(file);
if (stat.isDirectory()) file = path.join(file, "index.html");
await fsp.access(file);
} catch {
file = path.join(base, "index.html"); // SPA fallback
}
let content: Buffer;
try {
content = await fsp.readFile(file);
} catch {
return c.json(errorBody("not_found", "资源不存在。"), 404);
}
const type = CONTENT_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
return new Response(new Uint8Array(content), {
status: 200,
headers: { "Content-Type": type },
});
});
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Auth middleware: cookie -> auth_session ->
* user injected into c.var.
*
* Accessing a protected API while logged out -> 401 `{error:{code:"unauthorized"}}`.
* CSRF (MVP): SameSite=Lax cookie + write requests only accept
* `Content-Type: application/json` (an HTML form can't forge that Content-Type),
* see the README security notes.
*/
import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { HttpError } from "../http/errors.js";
import type { UserRow } from "../db/repos/users.js";
import type { AuthService } from "./service.js";
/** Session cookie name. */
export const SESSION_COOKIE = "penguin_session";
/** Hono env: variables injected by the auth middleware. */
export type AppEnv = {
Variables: {
user: UserRow;
};
};
/** Gets the current user (available after authMiddleware). */
export function currentUser(c: { var: { user: UserRow } }): UserRow {
return c.var.user;
}
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null;
if (!user) {
throw new HttpError(401, "unauthorized", "未登录或登录已过期。");
}
c.set("user", user);
await next();
};
}
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Content-Type defense for write requests: a write request with a Content-Type
* other than application/json is rejected (a request with no Content-Type and an
* empty body is let through — an HTML form always carries a form-type Content-Type).
*/
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。");
}
}
await next();
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Password hashing (slow-hash storage).
*
* Uses node:crypto's scrypt (built-in, no extra dependency, meets the same
* slow-hash requirement as bcrypt/argon2). Storage format:
* `scrypt$N$r$p$<salt b64>$<hash b64>` — parameters are stored alongside the hash,
* so old hashes remain verifiable after future parameter tuning; comparison uses
* timingSafeEqual to guard against timing side-channels.
*/
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SALT_BYTES = 16;
const KEY_BYTES = 64;
function scryptAsync(
password: string,
salt: Buffer,
keyLen: number,
n: number,
r: number,
p: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
}
/** Generates a password hash in `scrypt$N$r$p$salt$hash` format. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_BYTES);
const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
return [
"scrypt",
String(SCRYPT_N),
String(SCRYPT_R),
String(SCRYPT_P),
salt.toString("base64"),
key.toString("base64"),
].join("$");
}
/** Verifies a password; returns false if the stored string has an invalid format (never throws, so the login path can uniformly treat it as a credential error). */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const n = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[4]!, "base64");
expected = Buffer.from(parts[5]!, "base64");
} catch {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
let actual: Buffer;
try {
actual = await scryptAsync(password, salt, expected.length, n, r, p);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Auth service: built-in admin seeding /
* login / logout / password change / session validation.
*
* - No open registration: on startup, if there are no users at all, the built-in
* admin `admin` is seeded (initial password admin123), and it adopts
* `default_project`; all other users are created by an admin via the user
* backend (admin-service).
* - An initial password (whether seeded or set by an admin) is flagged with
* password_is_initial, which the frontend uses to prompt for a password change soon.
* - Sessions: a 32-byte random token, with only its sha256 hash stored in the DB;
* valid for 7 days, with sliding renewal once less than 6 days remain.
*/
import { createHash, randomBytes } from "node:crypto";
import type { UserInfo } from "../api/types.js";
import { HttpError } from "../http/errors.js";
import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js";
import type { UserRow, UsersRepo } from "../db/repos/users.js";
import { hashPassword, verifyPassword } from "./password.js";
export const MIN_PASSWORD_LENGTH = 8;
/** Built-in admin: user_id and initial password (matches the README and login-page hint). */
export const ADMIN_USER_ID = "admin";
export const ADMIN_INITIAL_PASSWORD = "admin123";
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function toUserInfo(row: UserRow): UserInfo {
return {
userId: row.userId,
isAdmin: row.isAdmin,
passwordIsInitial: row.passwordIsInitial,
createdAt: row.createdAt,
};
}
export interface AuthServiceDeps {
users: UsersRepo;
authSessions: AuthSessionsRepo;
/** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */
provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise<void>;
sessionTtlMs: number;
sessionRenewMs: number;
now?: () => Date;
}
export class AuthService {
private readonly now: () => Date;
constructor(private readonly deps: AuthServiceDeps) {
this.now = deps.now ?? (() => new Date());
}
/**
* Startup seeding (idempotent): creates the built-in admin and adopts
* default_project when the users table is empty; if the initial Project fails,
* the user row is rolled back and the server retries on next startup.
*/
async seedAdmin(): Promise<void> {
if (this.deps.users.count() > 0) return;
const user: UserRow = {
userId: ADMIN_USER_ID,
passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD),
isAdmin: true,
passwordIsInitial: true,
createdAt: this.now().toISOString(),
};
this.deps.users.insert(user);
try {
await this.deps.provisionInitialProject(user, true);
} catch (err) {
this.deps.users.delete(user.userId);
throw err;
}
}
async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {
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", "用户名或密码错误。");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
}
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
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", "当前密码不正确。");
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
logout(token: string): void {
this.deps.authSessions.delete(sha256Hex(token));
}
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
authenticate(token: string): UserRow | null {
const tokenHash = sha256Hex(token);
const session = this.deps.authSessions.findByTokenHash(tokenHash);
if (!session) return null;
const now = this.now();
const expiresAt = Date.parse(session.expiresAt);
if (!(expiresAt > now.getTime())) {
this.deps.authSessions.delete(tokenHash);
return null;
}
if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
this.deps.authSessions.touch(
tokenHash,
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
);
}
return this.deps.users.findById(session.userId);
}
private issueSession(userId: string): string {
const token = randomBytes(32).toString("base64url");
const now = this.now();
this.deps.authSessions.insert({
tokenHash: sha256Hex(token),
userId,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
});
return token;
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Server runtime config (ServerConfig) — parsed from environment variables.
*
* The data root directory is shared with the SDK / CLI (`resolveRoot()`:
* PENGUIN_HOME or ~/.penguin/data); the SQLite index database defaults to
* `<root>/web.db` (overridable via PENGUIN_WEB_DB, tests use ":memory:").
* In production, the SPA is served statically once the frontend build output
* directory (PENGUIN_WEB_DIST, the bundled web-dist/, or ../web/dist) is
* detected to exist.
* Docs: /docs/configuration § "Environment variables".
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveRoot } from "@prismshadow/penguin-core";
export interface ServerConfig {
/** Local data root directory (shared with the SDK/CLI). */
root: string;
/** HTTP listen address and port (defaults to 127.0.0.1:7364, deliberately avoiding common ports like 3000/8080). */
host: string;
port: number;
/** SQLite database path; ":memory:" for test injection. */
dbPath: string;
/** Frontend static assets directory; whether it's enabled is decided by checking existence when the app is assembled. */
webDist: string;
/** Login session validity period (7 days). */
authSessionTtlMs: number;
/** Sliding renewal threshold: if the remaining validity is below this value when validation succeeds, it's renewed to the full TTL (renews under 6 days). */
authSessionRenewMs: number;
}
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Default frontend build output directory, first match wins:
* - `<this package>/web-dist`: npm package layout — the release workflow copies the built
* web assets into the published package, so an `npm install` gets the Web UI too;
* - `<this package>/../web/dist`: monorepo layout (resolves the same whether running
* from src or dist), also the fallback when neither exists.
*/
function defaultWebDist(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
const bundled = path.resolve(here, "..", "web-dist");
if (fs.existsSync(bundled)) return bundled;
return path.resolve(here, "..", "..", "web", "dist");
}
/** Parses server config from environment variables (PORT / HOST / PENGUIN_HOME / PENGUIN_WEB_DIST / PENGUIN_WEB_DB). */
export function resolveServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const root = env.PENGUIN_HOME ?? resolveRoot();
// An empty PORT string is treated as unset (the common `.env` case of an empty
// `PORT=`): Number("") === 0 would pass the range check and bind to a random
// 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}`);
}
return {
root,
host: env.HOST ?? "127.0.0.1",
port,
dbPath: env.PENGUIN_WEB_DB ?? path.join(root, "web.db"),
webDist: env.PENGUIN_WEB_DIST ?? defaultWebDist(),
authSessionTtlMs: 7 * DAY_MS,
authSessionRenewMs: 6 * DAY_MS,
};
}
+28
View File
@@ -0,0 +1,28 @@
/**
* SQLite connection & initialization (node:sqlite DatabaseSync).
*
* Single process, single writer: a synchronous API is sufficient and avoids a connection
* pool; WAL mode and foreign key constraints are enabled. Table-creation SQL runs on open
* (idempotent), with no migration branches (product not yet released).
*/
import { mkdirSync } from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { SCHEMA_SQL } from "./schema.js";
// Fetch the runtime module via process.getBuiltinModule (node >=22.3): avoids static
// resolution of `node:sqlite` by bundlers/vite (some tools' builtin lists don't yet
// recognize this experimental module).
const sqlite = process.getBuiltinModule("node:sqlite");
/** Open (creating if necessary) the database: ensure the parent directory exists, set PRAGMAs, run table creation. */
export function openDatabase(dbPath: string): DatabaseSync {
if (dbPath !== ":memory:") {
mkdirSync(path.dirname(dbPath), { recursive: true });
}
const db = new sqlite.DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
db.exec(SCHEMA_SQL);
return db;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* agents table repo: Agent index; name/description live in system_config.yaml.
*/
import type { DatabaseSync } from "node:sqlite";
export interface AgentRow {
projectId: string;
agentId: string;
createdAt: string;
}
export class AgentsRepo {
constructor(private readonly db: DatabaseSync) {}
/** Idempotent insert: backfills an untracked Agent discovered via directory scan; shared with explicit creation. */
insertOrIgnore(row: AgentRow): void {
this.db
.prepare("INSERT OR IGNORE INTO agents (project_id, agent_id, created_at) VALUES (?, ?, ?)")
.run(row.projectId, row.agentId, row.createdAt);
}
exists(projectId: string, agentId: string): boolean {
const r = this.db
.prepare("SELECT 1 AS x FROM agents WHERE project_id = ? AND agent_id = ?")
.get(projectId, agentId);
return r !== undefined;
}
list(projectId: string): AgentRow[] {
const rows = this.db
.prepare("SELECT * FROM agents WHERE project_id = ? ORDER BY created_at ASC, agent_id ASC")
.all(projectId);
return rows.map((r) => ({
projectId: r.project_id as string,
agentId: r.agent_id as string,
createdAt: r.created_at as string,
}));
}
delete(projectId: string, agentId: string): void {
this.db
.prepare("DELETE FROM agents WHERE project_id = ? AND agent_id = ?")
.run(projectId, agentId);
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM agents WHERE project_id = ?").run(projectId);
}
}
@@ -0,0 +1,57 @@
/**
* auth_sessions table repo (server-side sessions backing the HttpOnly cookie).
*
* Stores only the sha256(token) hex hash; the raw token appears only in the cookie.
*/
import type { DatabaseSync } from "node:sqlite";
export interface AuthSessionRow {
tokenHash: string;
userId: string;
createdAt: string;
expiresAt: string;
}
export class AuthSessionsRepo {
constructor(private readonly db: DatabaseSync) {}
insert(row: AuthSessionRow): void {
this.db
.prepare(
"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
)
.run(row.tokenHash, row.userId, row.createdAt, row.expiresAt);
}
findByTokenHash(tokenHash: string): AuthSessionRow | null {
const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
if (!r) return null;
return {
tokenHash: r.token_hash as string,
userId: r.user_id as string,
createdAt: r.created_at as string,
expiresAt: r.expires_at as string,
};
}
/** Sliding renewal: update the expiration time. */
touch(tokenHash: string, expiresAt: string): void {
this.db
.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?")
.run(expiresAt, tokenHash);
}
delete(tokenHash: string): void {
this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
}
/** Opportunistically clean up expired sessions (called during login/validation). */
deleteExpired(nowIso: string): void {
this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
}
/** Clear all sessions for a user (forces re-login after an admin resets the password). */
deleteByUser(userId: string): void {
this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
}
}
+219
View File
@@ -0,0 +1,219 @@
/**
* error_records table repo: one row per error the server catches.
*
* Key difference from usage_records: **all attribution columns are nullable** — errors
* from the login/register endpoints have no Project, and process-level catch-alls
* (uncaughtException) don't even have a request. These **unattributed errors are visible
* only to admins** (`ErrorFilter.includeGlobal`, defaults to false): they represent other
* users' login failures, misdirected Session access, and process crashes (the message may
* contain internal paths and variable values), so surfacing them in any regular member's
* statistics center would be a cross-tenant information leak — regular members see only
* their own Project's errors. Admins still see the category that most needs visibility.
*
* **Row cap** (MAX_ROWS): errors often come in storms (an API scan producing a wall of
* 404s, a tool failing repeatedly in a loop), and an uncapped table would blow up disk
* usage. So the insert path enforces the cap: check capacity every PRUNE_EVERY inserts
* (insertion sits on the error-handling path, so we avoid COUNT on every call), and when
* over the limit, evict the oldest rows in ascending id order. The excess is computed via
* an **exact COUNT**, not an approximation like `id <= MAX(id) - :max` — after
* deleteByProject removes rows, id and row count diverge, and the approximation would
* wrongly delete still-valid data within the cap. The first line of defense is
* ErrorRecorder's short-window deduplication.
*/
import type { DatabaseSync } from "node:sqlite";
/** Row cap: once exceeded, oldest rows are evicted in ascending id order (see file header). */
export const MAX_ROWS = 20000;
/** Check capacity every N inserts (see file header: insertion sits on the error-handling path, so we avoid COUNT on every call). */
export const PRUNE_EVERY = 200;
/** Capacity parameters (default to the two constants above; tests inject small values to exercise the eviction path). */
export interface ErrorsRepoLimits {
maxRows?: number;
pruneEvery?: number;
}
export interface ErrorRecordInsert {
ts: string;
date: string;
projectId: string | null;
agentId: string | null;
sessionId: string | null;
source: string;
/** expected (HttpError, business 4xx) | unexpected (500 / unforeseen runtime error). */
kind: string;
code: string;
status: number | null;
message: string;
}
/** Generic filter: date range + agent (errors have no Model dimension, so no model filter). */
export interface ErrorFilter {
from?: string;
to?: string;
agentId?: string;
/** Whether to include unattributed errors (`project_id IS NULL`): admins only, defaults to false (see file header). */
includeGlobal?: boolean;
}
/** Total error count and how many are unexpected (stats for the statistics center's error panel). */
export interface ErrorSummary {
total: number;
unexpected: number;
}
/** Occurrence count for one source · code pair (the error panel's "most common" metric). */
export interface ErrorCodeCount {
source: string;
code: string;
kind: string;
count: number;
}
/** One error summary row (a row in the error panel's table). */
export interface ErrorItem {
ts: string;
source: string;
code: string;
kind: string;
message: string;
}
export class ErrorsRepo {
private readonly maxRows: number;
private readonly pruneEvery: number;
/** Insert count since the last capacity check (see file header: avoids COUNT on every call). */
private sinceCheck = 0;
constructor(
private readonly db: DatabaseSync,
limits: ErrorsRepoLimits = {},
) {
this.maxRows = limits.maxRows ?? MAX_ROWS;
this.pruneEvery = limits.pruneEvery ?? PRUNE_EVERY;
}
insert(r: ErrorRecordInsert): void {
this.db
.prepare(
`INSERT INTO error_records
(ts, date, project_id, agent_id, session_id, source, kind, code, status, message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
r.ts,
r.date,
r.projectId,
r.agentId,
r.sessionId,
r.source,
r.kind,
r.code,
r.status,
r.message,
);
if (++this.sinceCheck >= this.pruneEvery) {
this.sinceCheck = 0;
this.pruneOverflow();
}
}
/** Capacity enforcement (see file header): compute the exact excess via COUNT, then delete the oldest rows in ascending id order. */
private pruneOverflow(): void {
const row = this.db.prepare("SELECT COUNT(*) AS n FROM error_records").get()!;
const excess = (row.n as number) - this.maxRows;
if (excess <= 0) return;
this.db
.prepare(
`DELETE FROM error_records WHERE id IN (
SELECT id FROM error_records ORDER BY id ASC LIMIT :excess
)`,
)
.run({ excess });
}
/** WHERE fragment and named params: this Project (admins additionally get unattributed errors), plus optional date/agent filter. */
private conds(
projectId: string,
f: ErrorFilter,
): { where: string; params: Record<string, string> } {
// Unattributed errors (login failures, process crashes, ...) are visible only to admins; otherwise it's a cross-tenant leak — see file header.
const conds = [
f.includeGlobal === true ? "(project_id = :pid OR project_id IS NULL)" : "project_id = :pid",
];
const params: Record<string, string> = { pid: projectId };
if (f.from !== undefined) {
conds.push("date >= :from");
params.from = f.from;
}
if (f.to !== undefined) {
conds.push("date <= :to");
params.to = f.to;
}
if (f.agentId !== undefined) {
// Filtering by Agent naturally leaves only that Agent's errors (HTTP / process-level errors have no agent_id).
conds.push("agent_id = :agentId");
params.agentId = f.agentId;
}
return { where: conds.join(" AND "), params };
}
/** Total count + how many are unexpected. */
summary(projectId: string, f: ErrorFilter = {}): ErrorSummary {
const { where, params } = this.conds(projectId, f);
const row = this.db
.prepare(
`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN kind = 'unexpected' THEN 1 ELSE 0 END), 0) AS unexpected
FROM error_records WHERE ${where}`,
)
.get(params)!;
return { total: row.total as number, unexpected: row.unexpected as number };
}
/** The most frequent source · code (ties broken by code's lexicographic order); null if there are no errors. */
topCode(projectId: string, f: ErrorFilter = {}): ErrorCodeCount | null {
const { where, params } = this.conds(projectId, f);
const row = this.db
.prepare(
`SELECT source, code, kind, COUNT(*) AS count
FROM error_records WHERE ${where}
GROUP BY source, code, kind
ORDER BY count DESC, code ASC
LIMIT 1`,
)
.get(params);
if (!row) return null;
return {
source: row.source as string,
code: row.code as string,
kind: row.kind as string,
count: row.count as number,
};
}
/** The most recent `limit` entries (reverse chronological order). */
recent(projectId: string, f: ErrorFilter = {}, limit = 20): ErrorItem[] {
const { where, params } = this.conds(projectId, f);
const rows = this.db
.prepare(
`SELECT ts, source, code, kind, message
FROM error_records WHERE ${where}
ORDER BY id DESC LIMIT :limit`,
)
.all({ ...params, limit });
return rows.map((r) => ({
ts: r.ts as string,
source: r.source as string,
code: r.code as string,
kind: r.kind as string,
message: r.message as string,
}));
}
/** Cascading cleanup on Project deletion (unattributed errors belong to no Project and are unaffected). */
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM error_records WHERE project_id = ?").run(projectId);
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Repo for the project_members table: only member
* authorization relationships — the owner is never in this table.
*/
import type { DatabaseSync } from "node:sqlite";
export interface MemberRow {
projectId: string;
userId: string;
createdAt: string;
}
export class MembersRepo {
constructor(private readonly db: DatabaseSync) {}
insert(row: MemberRow): void {
this.db
.prepare("INSERT INTO project_members (project_id, user_id, created_at) VALUES (?, ?, ?)")
.run(row.projectId, row.userId, row.createdAt);
}
isMember(projectId: string, userId: string): boolean {
const r = this.db
.prepare("SELECT 1 AS x FROM project_members WHERE project_id = ? AND user_id = ?")
.get(projectId, userId);
return r !== undefined;
}
list(projectId: string): MemberRow[] {
const rows = this.db
.prepare(
"SELECT project_id, user_id, created_at FROM project_members WHERE project_id = ? ORDER BY created_at ASC",
)
.all(projectId);
return rows.map((r) => ({
projectId: r.project_id as string,
userId: r.user_id as string,
createdAt: r.created_at as string,
}));
}
delete(projectId: string, userId: string): void {
this.db
.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?")
.run(projectId, userId);
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* Repo for the projects table: an index of ownership
* relationships; the display name lives in project_config.toml.
*/
import type { DatabaseSync } from "node:sqlite";
import type { ProjectRole } from "../../api/types.js";
export interface ProjectRow {
projectId: string;
ownerUserId: string;
createdAt: string;
}
export interface AccessibleProjectRow extends ProjectRow {
role: ProjectRole;
}
function mapRow(r: Record<string, unknown>): ProjectRow {
return {
projectId: r.project_id as string,
ownerUserId: r.owner_user_id as string,
createdAt: r.created_at as string,
};
}
export class ProjectsRepo {
constructor(private readonly db: DatabaseSync) {}
insert(row: ProjectRow): void {
this.db
.prepare("INSERT INTO projects (project_id, owner_user_id, created_at) VALUES (?, ?, ?)")
.run(row.projectId, row.ownerUserId, row.createdAt);
}
findById(projectId: string): ProjectRow | null {
const r = this.db.prepare("SELECT * FROM projects WHERE project_id = ?").get(projectId);
return r ? mapRow(r) : null;
}
/** All Projects (used by the scheduler's reconciliation scan), ascending by creation time. */
listAll(): ProjectRow[] {
const rows = this.db
.prepare("SELECT * FROM projects ORDER BY created_at ASC, project_id ASC")
.all();
return rows.map((r) => mapRow(r as Record<string, unknown>));
}
/** Projects owned by or shared with the current user (including their role), ascending by creation time. */
listAccessible(userId: string): AccessibleProjectRow[] {
const rows = this.db
.prepare(
`SELECT p.project_id, p.owner_user_id, p.created_at,
CASE WHEN p.owner_user_id = :uid THEN 'owner' ELSE 'member' END AS role
FROM projects p
WHERE p.owner_user_id = :uid
OR EXISTS (SELECT 1 FROM project_members m
WHERE m.project_id = p.project_id AND m.user_id = :uid)
ORDER BY p.created_at ASC, p.project_id ASC`,
)
.all({ uid: userId });
return rows.map((r) => ({
...mapRow(r),
role: r.role as ProjectRole,
}));
}
/** All Projects owned by a given user (used for cascading cleanup when an admin deletes a user). */
listByOwner(userId: string): ProjectRow[] {
const rows = this.db
.prepare("SELECT * FROM projects WHERE owner_user_id = ? ORDER BY created_at ASC")
.all(userId);
return rows.map(mapRow);
}
delete(projectId: string): void {
this.db.prepare("DELETE FROM projects WHERE project_id = ?").run(projectId);
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Repo for schedule runtime state: intent and state are separate — the file is
* declarative intent, this table only records runtime state such as
* "fired before / last fired / missed / disabled" plus the creator.
*
* Identity rule: a change to `start_at` is treated as a new task instance
* (registerOrSync resets the trigger state); a change to the file content fingerprint
* only clears the disabled flag (the file becomes effective again after reconciliation).
*/
import type { DatabaseSync } from "node:sqlite";
export interface ScheduleStateRow {
projectId: string;
agentId: string;
name: string;
creatorUserId: string | null;
startAtMs: number;
defHash: string;
lastSlotMs: number | null;
lastFiredAt: string | null;
firedOnce: boolean;
missed: boolean;
invalidReason: string | null;
}
function mapRow(r: Record<string, unknown>): ScheduleStateRow {
return {
projectId: r.project_id as string,
agentId: r.agent_id as string,
name: r.name as string,
creatorUserId: (r.creator_user_id as string | null) ?? null,
startAtMs: Number(r.start_at_ms),
defHash: r.def_hash as string,
lastSlotMs: r.last_slot_ms === null ? null : Number(r.last_slot_ms),
lastFiredAt: (r.last_fired_at as string | null) ?? null,
firedOnce: Number(r.fired_once) === 1,
missed: Number(r.missed) === 1,
invalidReason: (r.invalid_reason as string | null) ?? null,
};
}
export class SchedulesRepo {
constructor(private readonly db: DatabaseSync) {}
find(projectId: string, agentId: string, name: string): ScheduleStateRow | null {
const r = this.db
.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? AND name = ?")
.get(projectId, agentId, name);
return r ? mapRow(r as Record<string, unknown>) : null;
}
listByAgent(projectId: string, agentId: string): ScheduleStateRow[] {
const rows = this.db
.prepare("SELECT * FROM schedule_state WHERE project_id = ? AND agent_id = ? ORDER BY name")
.all(projectId, agentId);
return rows.map((r) => mapRow(r as Record<string, unknown>));
}
/**
* Register or sync a task's runtime state, returning the synced row plus a `fresh`
* flag:
* - Insert if it doesn't exist (creator is only persisted at this point; a hand-edited
* file gets registered via reconciliation, with creator falling back to the Project
* owner);
* - A change to `start_at` resets the trigger state (a new task instance);
* - Otherwise, a change to the file fingerprint only clears the disabled flag.
* `fresh` = this call was an insert or reset — the scheduler only establishes its
* "missed, don't backfill" baseline at this moment; afterward, last_slot being NULL
* only means "no scheduled time has been consumed yet", and it must fire normally once
* reached.
*/
registerOrSync(args: {
projectId: string;
agentId: string;
name: string;
startAtMs: number;
defHash: string;
creatorUserId: string | null;
}): { row: ScheduleStateRow; fresh: boolean } {
const existing = this.find(args.projectId, args.agentId, args.name);
let fresh = false;
if (!existing) {
this.db
.prepare(
`INSERT INTO schedule_state
(project_id, agent_id, name, creator_user_id, start_at_ms, def_hash)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
args.projectId,
args.agentId,
args.name,
args.creatorUserId,
args.startAtMs,
args.defHash,
);
fresh = true;
} else if (existing.startAtMs !== args.startAtMs) {
this.db
.prepare(
`UPDATE schedule_state
SET start_at_ms = ?, def_hash = ?, last_slot_ms = NULL, last_fired_at = NULL,
fired_once = 0, missed = 0, invalid_reason = NULL
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.run(args.startAtMs, args.defHash, args.projectId, args.agentId, args.name);
fresh = true;
} else if (existing.defHash !== args.defHash) {
this.db
.prepare(
`UPDATE schedule_state SET def_hash = ?, invalid_reason = NULL
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.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 登记后读取失败");
return { row, fresh };
}
/** Advance the consumed scheduled time (advances whether triggered or skipped; restarts don't re-trigger). */
markSlot(projectId: string, agentId: string, name: string, slotMs: number): void {
this.db
.prepare(
`UPDATE schedule_state SET last_slot_ms = ?
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.run(slotMs, projectId, agentId, name);
}
/** Record an actual send (also sets fired_once for a one-shot task). */
markFired(
projectId: string,
agentId: string,
name: string,
firedAt: string,
oneShot: boolean,
): void {
this.db
.prepare(
`UPDATE schedule_state SET last_fired_at = ?, fired_once = CASE WHEN ? THEN 1 ELSE fired_once END
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.run(firedAt, oneShot ? 1 : 0, projectId, agentId, name);
}
/** Missed marker for a one-shot task (the scheduled time had already passed at startup/registration reconciliation; missed means not backfilled). */
markMissed(projectId: string, agentId: string, name: string): void {
this.db
.prepare(
`UPDATE schedule_state SET missed = 1
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.run(projectId, agentId, name);
}
/** Mark as disabled (e.g. the bound Session was deleted); cleared via registerOrSync after the file is modified. */
markInvalid(projectId: string, agentId: string, name: string, reason: string): void {
this.db
.prepare(
`UPDATE schedule_state SET invalid_reason = ?
WHERE project_id = ? AND agent_id = ? AND name = ?`,
)
.run(reason, projectId, agentId, name);
}
/** Deleting the file removes the task: clears its runtime state. */
delete(projectId: string, agentId: string, name: string): void {
this.db
.prepare("DELETE FROM schedule_state WHERE project_id = ? AND agent_id = ? AND name = ?")
.run(projectId, agentId, name);
}
/** Reconciliation cleanup: deletes state rows under this Agent that aren't in the current file list, returning the removed names. */
deleteMissing(projectId: string, agentId: string, presentNames: string[]): string[] {
const rows = this.listByAgent(projectId, agentId);
const present = new Set(presentNames);
const removed: string[] = [];
for (const row of rows) {
if (!present.has(row.name)) {
this.delete(projectId, agentId, row.name);
removed.push(row.name);
}
}
return removed;
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM schedule_state WHERE project_id = ?").run(projectId);
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* sessions table repo:
* Session index, approval mode, and auto-generated title; Session-level routes use this to look up project ownership.
*/
import type { DatabaseSync } from "node:sqlite";
import type { ApprovalMode } from "../../api/types.js";
export interface SessionRow {
sessionId: string;
projectId: string;
agentId: string;
/** Provider group of the session's model (pairs with `modelId` to form the model reference). */
provider: string;
/** Upstream model_id of the session's model (sent as-is to AgentHub; never concatenated). */
modelId: string;
workspace: string;
approvalMode: ApprovalMode;
/** Auto-generated session title; NULL = not yet generated (frontend shows "New Conversation"). */
title: string | null;
/** Archive timestamp, ISO; NULL = not archived (omitting on insert defaults to NULL). */
archivedAt?: string | null;
/** Session origin: NULL = user-created; schedule = triggered by a Schedule; subagent = registered as a subagent session. */
source?: "schedule" | "subagent" | null;
createdAt: string;
}
function mapRow(r: Record<string, unknown>): SessionRow {
return {
sessionId: r.session_id as string,
projectId: r.project_id as string,
agentId: r.agent_id as string,
provider: r.provider as string,
modelId: r.model_id as string,
workspace: r.workspace as string,
approvalMode: r.approval_mode as ApprovalMode,
title: (r.title as string | null) ?? null,
archivedAt: (r.archived_at as string | null) ?? null,
source: (r.source as "schedule" | "subagent" | null) ?? null,
createdAt: r.created_at as string,
};
}
export class SessionsRepo {
constructor(private readonly db: DatabaseSync) {}
insert(row: SessionRow): void {
this.db
.prepare(
`INSERT INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
row.projectId,
row.agentId,
row.provider,
row.modelId,
row.workspace,
row.approvalMode,
row.title,
row.source ?? null,
row.createdAt,
);
}
/** Idempotent insert: used when Trace directory discovery backfills a row (concurrent listing discovering the same Session no longer triggers a UNIQUE violation). */
insertOrIgnore(row: SessionRow): void {
this.db
.prepare(
`INSERT OR IGNORE INTO sessions (session_id, project_id, agent_id, provider, model_id, workspace, approval_mode, title, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
row.sessionId,
row.projectId,
row.agentId,
row.provider,
row.modelId,
row.workspace,
row.approvalMode,
row.title,
row.source ?? null,
row.createdAt,
);
}
findById(sessionId: string): SessionRow | null {
const r = this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
return r ? mapRow(r) : null;
}
listByAgent(projectId: string, agentId: string): SessionRow[] {
const rows = this.db
.prepare("SELECT * FROM sessions WHERE project_id = ? AND agent_id = ?")
.all(projectId, agentId);
return rows.map(mapRow);
}
listByProject(projectId: string): SessionRow[] {
const rows = this.db.prepare("SELECT * FROM sessions WHERE project_id = ?").all(projectId);
return rows.map(mapRow);
}
updateApprovalMode(sessionId: string, mode: ApprovalMode): void {
this.db
.prepare("UPDATE sessions SET approval_mode = ? WHERE session_id = ?")
.run(mode, sessionId);
}
updateTitle(sessionId: string, title: string): void {
this.db.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId);
}
/**
* Writes only if the title is still NULL. Subagent-session registration and Trace
* directory discovery backfill can race on the same row, and both are insert-only:
* whichever inserts first determines the title. Discovery backfill can only supply NULL,
* so this method fills the title back in without overwriting an existing one (including
* a user rename or an already-generated title).
*/
updateTitleIfNull(sessionId: string, title: string): void {
this.db
.prepare("UPDATE sessions SET title = ? WHERE session_id = ? AND title IS NULL")
.run(title, sessionId);
}
/** Archive / unarchive (archivedAt = ISO or NULL). */
setArchived(sessionId: string, archivedAt: string | null): void {
this.db
.prepare("UPDATE sessions SET archived_at = ? WHERE session_id = ?")
.run(archivedAt, sessionId);
}
/** Self-healing: after rebuilding a broken Session with no Trace, update the primary key to the new id. */
replaceId(oldSessionId: string, newSessionId: string): void {
this.db
.prepare("UPDATE sessions SET session_id = ? WHERE session_id = ?")
.run(newSessionId, oldSessionId);
}
deleteByAgent(projectId: string, agentId: string): void {
this.db
.prepare("DELETE FROM sessions WHERE project_id = ? AND agent_id = ?")
.run(projectId, agentId);
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM sessions WHERE project_id = ?").run(projectId);
}
deleteById(sessionId: string): void {
this.db.prepare("DELETE FROM sessions WHERE session_id = ?").run(sessionId);
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* ui_prefs table repo (UI preferences): free-form JSON storage.
*/
import type { DatabaseSync } from "node:sqlite";
export class UiPrefsRepo {
constructor(private readonly db: DatabaseSync) {}
/** Returns the raw JSON string; null if never set. */
get(userId: string): string | null {
const r = this.db.prepare("SELECT prefs_json FROM ui_prefs WHERE user_id = ?").get(userId);
return r ? (r.prefs_json as string) : null;
}
set(userId: string, prefsJson: string): void {
this.db
.prepare(
`INSERT INTO ui_prefs (user_id, prefs_json) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET prefs_json = excluded.prefs_json`,
)
.run(userId, prefsJson);
}
}
+247
View File
@@ -0,0 +1,247 @@
/**
* usage_records table repo:
* one row per token_usage (per-request bucket). Stores Token counts only, not cost —
* cost is computed on the fly by usage-service against current pricing at query time,
* so every aggregation is broken down by the `(provider, model_id)` pair and returns
* raw Token sums (a model_id shared across providers is aggregated separately; never concatenated).
*/
import type { DatabaseSync } from "node:sqlite";
import type { UsageGroupBy } from "../../api/types.js";
export interface UsageRecordInsert {
ts: string;
date: string;
projectId: string;
agentId: string;
sessionId: string;
originSessionId: string | null;
/** Provider group (pairs with modelId to form the attribution key). */
provider: string;
/** Upstream model id (pairs with provider). */
modelId: string;
cacheRead: number;
cacheWrite: number;
output: number;
total: number;
/** Request outcome; defaults to completed (success, carries tokens). Failed requests are stored with 0 tokens + status, for success-rate calculations. */
status?: string;
}
/** Generic filter: date range + agent / model dimensions (cost center top bar switches by agent/model). */
export interface UsageFilter {
from?: string;
to?: string;
agentId?: string;
/** Provider filter paired with modelId (the frontend dropdown always sends them together). */
provider?: string;
modelId?: string;
}
/**
* Raw request success-rate counts for a single Model (paired reference).
* `total` is **the success-rate denominator**: all requests minus aborted — the user
* clicking "stop" is not a model failure, and counting it would make the success rate
* drop every time stop is pressed. `aborted` is counted separately for display.
*/
export interface UsageStatusCount {
provider: string;
modelId: string;
completed: number;
total: number;
aborted: number;
failed: number;
timeout: number;
malformed: number;
}
/** Raw Token sums for a single Model (paired reference) — the smallest unit for cost conversion. */
export interface UsageModelSums {
provider: string;
modelId: string;
cacheRead: number;
cacheWrite: number;
output: number;
total: number;
requests: number;
}
/** Raw Token sums by group key x Model. */
export interface UsageGroupModelSums extends UsageModelSums {
key: string;
}
/** groupBy dimension -> column name allowlist (prevents injection; only these four columns can be group keys). */
const GROUP_COLUMNS: Record<UsageGroupBy, string> = {
date: "date",
agent: "agent_id",
model: "model_id",
session: "session_id",
};
const SUM_COLUMNS = `COALESCE(SUM(cache_read), 0) AS cache_read,
COALESCE(SUM(cache_write), 0) AS cache_write,
COALESCE(SUM(output), 0) AS output,
COALESCE(SUM(total), 0) AS total,
COUNT(*) AS requests`;
function toSums(r: Record<string, unknown>): UsageModelSums {
return {
provider: r.provider as string,
modelId: r.model_id as string,
cacheRead: r.cache_read as number,
cacheWrite: r.cache_write as number,
output: r.output as number,
total: r.total as number,
requests: r.requests as number,
};
}
export class UsageRepo {
constructor(private readonly db: DatabaseSync) {}
insert(r: UsageRecordInsert): void {
this.db
.prepare(
`INSERT INTO usage_records
(ts, date, project_id, agent_id, session_id, origin_session_id, provider, model_id,
cache_read, cache_write, output, total, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
r.ts,
r.date,
r.projectId,
r.agentId,
r.sessionId,
r.originSessionId,
r.provider,
r.modelId,
r.cacheRead,
r.cacheWrite,
r.output,
r.total,
r.status ?? "completed",
);
}
/** WHERE fragment (project + optional date/agent/model) plus named params. */
private conds(
projectId: string,
f: UsageFilter,
): { where: string; params: Record<string, string> } {
const conds = ["project_id = :pid"];
const params: Record<string, string> = { pid: projectId };
if (f.from !== undefined) {
conds.push("date >= :from");
params.from = f.from;
}
if (f.to !== undefined) {
conds.push("date <= :to");
params.to = f.to;
}
if (f.agentId !== undefined) {
conds.push("agent_id = :agentId");
params.agentId = f.agentId;
}
if (f.provider !== undefined) {
conds.push("provider = :provider");
params.provider = f.provider;
}
if (f.modelId !== undefined) {
conds.push("model_id = :modelId");
params.modelId = f.modelId;
}
return { where: conds.join(" AND "), params };
}
/** Sums (broken down by paired reference): date range + optional agent/model filter. */
bucketByModel(projectId: string, f: UsageFilter = {}): UsageModelSums[] {
const { where, params } = this.conds(projectId, f);
const rows = this.db
.prepare(
`SELECT provider, model_id, ${SUM_COLUMNS}
FROM usage_records WHERE ${where}
GROUP BY provider, model_id`,
)
.all(params);
return rows.map(toSums);
}
/** Grouped aggregation (group key x paired reference breakdown): date range + optional agent/model filter. */
groupsByModel(
projectId: string,
groupBy: UsageGroupBy,
f: UsageFilter = {},
): UsageGroupModelSums[] {
const col = GROUP_COLUMNS[groupBy];
const { where, params } = this.conds(projectId, f);
const rows = this.db
.prepare(
`SELECT ${col} AS key, provider, model_id, ${SUM_COLUMNS}
FROM usage_records WHERE ${where}
GROUP BY ${col}, provider, model_id`,
)
.all(params);
return rows.map((r) => ({ key: r.key as string, ...toSums(r) }));
}
/**
* Raw success-rate counts per Model (paired reference) (completed / non-aborted requests):
* powers the cost center's "Model Success Rate" chart. The denominator excludes aborted
* (user-initiated interruption); failure breakdowns (failed / timeout / malformed) are
* also returned for hover display. Unknown statuses aren't broken out but still count
* toward the denominator (conservative: anything non-completed counts as a failure).
*/
statusByModel(projectId: string, f: UsageFilter = {}): UsageStatusCount[] {
const { where, params } = this.conds(projectId, f);
const count = (status: string) =>
`COALESCE(SUM(CASE WHEN status = '${status}' THEN 1 ELSE 0 END), 0) AS ${status}`;
const rows = this.db
.prepare(
`SELECT provider, model_id,
${count("completed")},
${count("aborted")},
${count("failed")},
${count("timeout")},
${count("malformed")},
COALESCE(SUM(CASE WHEN status <> 'aborted' THEN 1 ELSE 0 END), 0) AS total
FROM usage_records WHERE ${where} GROUP BY provider, model_id`,
)
.all(params);
return rows.map((r) => ({
provider: r.provider as string,
modelId: r.model_id as string,
completed: r.completed as number,
total: r.total as number,
aborted: r.aborted as number,
failed: r.failed as number,
timeout: r.timeout as number,
malformed: r.malformed as number,
}));
}
/** Distinct agent_id values seen for this Project (for filter dropdowns). */
distinctAgentIds(projectId: string): string[] {
const rows = this.db
.prepare(
"SELECT DISTINCT agent_id AS v FROM usage_records WHERE project_id = ? ORDER BY agent_id",
)
.all(projectId);
return rows.map((r) => r.v as string);
}
/** Distinct Model paired references seen for this Project (for filter dropdowns). */
distinctModels(projectId: string): Array<{ provider: string; modelId: string }> {
const rows = this.db
.prepare(
`SELECT DISTINCT provider, model_id FROM usage_records
WHERE project_id = ? ORDER BY provider, model_id`,
)
.all(projectId);
return rows.map((r) => ({ provider: r.provider as string, modelId: r.model_id as string }));
}
deleteByProject(projectId: string): void {
this.db.prepare("DELETE FROM usage_records WHERE project_id = ?").run(projectId);
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* users table repo: pure SQL wrapper, no business rules.
* user_id is the login name (a semantic id, specified at creation, immutable).
*/
import type { DatabaseSync } from "node:sqlite";
export interface UserRow {
userId: string;
passwordHash: string;
isAdmin: boolean;
/** Still using the initial password (seeded / set by an admin); cleared to 0 once the user changes it. */
passwordIsInitial: boolean;
createdAt: string;
}
function mapRow(r: Record<string, unknown>): UserRow {
return {
userId: r.user_id as string,
passwordHash: r.password_hash as string,
isAdmin: (r.is_admin as number) === 1,
passwordIsInitial: (r.password_is_initial as number) === 1,
createdAt: r.created_at as string,
};
}
export class UsersRepo {
constructor(private readonly db: DatabaseSync) {}
insert(row: UserRow): void {
this.db
.prepare(
"INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, created_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
row.userId,
row.passwordHash,
row.isAdmin ? 1 : 0,
row.passwordIsInitial ? 1 : 0,
row.createdAt,
);
}
findById(userId: string): UserRow | null {
const r = this.db.prepare("SELECT * FROM users WHERE user_id = ?").get(userId);
return r ? mapRow(r) : null;
}
/** All users (for the admin user backend), ordered by creation time ascending. */
list(): UserRow[] {
const rows = this.db.prepare("SELECT * FROM users ORDER BY created_at ASC, user_id ASC").all();
return rows.map(mapRow);
}
count(): number {
const r = this.db.prepare("SELECT COUNT(*) AS n FROM users").get();
return (r?.n as number) ?? 0;
}
/** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */
updatePassword(userId: string, passwordHash: string, isInitial: boolean): void {
this.db
.prepare("UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?")
.run(passwordHash, isInitial ? 1 : 0, userId);
}
/** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */
delete(userId: string): void {
this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
}
}
+104
View File
@@ -0,0 +1,104 @@
/**
* SQLite table-creation SQL.
*
* SQLite stores only indexes and aggregates: users / login sessions / Project authorization /
* Agent & Session indexes / usage summaries / error records / UI preferences. Agent State,
* Trace, and Workspace still follow the local directory-based storage rules.
* Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed once.
*/
export const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- 语义 id 即登录名:^[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
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY, -- sha256(token) hex;cookie 只存原始 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 天则续满)
);
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY, -- 目录名即 id;显示名在 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 不入此表
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
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (project_id, agent_id)
);
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> 拼接)
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(子会话)
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 日期目录同口径)
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 跨厂商分开聚合)
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 实时折算
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 ( -- 服务端异常捕获(统计中心「异常」)
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
date TEXT NOT NULL, -- 本地日期 yyyy-mm-dd(与 usage_records 同口径)
project_id TEXT, -- 可空:登录/注册、进程级异常没有 Project 上下文
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/运行时)
code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...
status INTEGER, -- HTTP 状态码;非 HTTP 来源为 NULL
message TEXT NOT NULL -- 截断 500 字符(不存堆栈:堆栈只进日志)
);
CREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);
CREATE TABLE IF NOT EXISTS schedule_state ( -- 定时任务运行状态(文件是声明式意图,系统不写回)
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 即正常
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
);
`;
+55
View File
@@ -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);
}
+47
View File
@@ -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;
}
+86
View File
@@ -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;
}
+46
View File
@@ -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;
}
+69
View File
@@ -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;
}
+24
View File
@@ -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;
}
+65
View File
@@ -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;
}
+178
View File
@@ -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}`);
}
+430
View File
@@ -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;
}
+139
View File
@@ -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;
}
+48
View File
@@ -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;
}
+58
View File
@@ -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;
}
+13
View File
@@ -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?.()),
]);
}
+94
View File
@@ -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();
}
});
}
+169
View File
@@ -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;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Server startup entry point: dotenv → config → assembly → listen
* → graceful shutdown.
*
* SIGINT / SIGTERM: interrupt all active runs (pending approvals converge to deny), wait
* ≤5s for wrap-up, then close HTTP and SQLite. Tests never go through this file
* (injected via app.request() instead).
* There's also a process-level error fallback (uncaughtException / unhandledRejection):
* persist + log, with the fatal one still shutting down per existing semantics (see the
* comment below).
*/
import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
loadDotenv({ quiet: true });
const config = resolveServerConfig();
const deps = buildAppDeps(config);
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin (initial password admin123) and adopts default_project when the users table is empty.
await deps.authService.seedAdmin();
// Schedule scheduler: startup reconciliation (missed, don't backfill) + periodic scan; only active while the server is running.
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(`SQLite: ${config.dbPath}`);
});
let shuttingDown = false;
async function shutdown(signal: string, exitCode = 0): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`收到 ${signal},正在关停…`);
deps.scheduler.stop();
await deps.manager.shutdown(5000);
deps.channels.dispose();
server.close(() => {
deps.db.close();
process.exit(exitCode);
});
// Fallback: a long-lived SSE connection may block the close callback, so force exit after 1s.
setTimeout(() => process.exit(exitCode), 1000).unref();
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
// Process-level error fallback: once a background
// fire-and-forget promise (title generation, Session drive, etc.) throws, the error
// 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}`);
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
// with a nonzero code (equivalent to Node's default crash exit, just with an extra
// persist and graceful wrap-up).
// Must exit even if shutdown itself errors — never let "caught a fatal error" turn
// into "the process limps along in a broken state".
void shutdown("uncaughtException", 1).catch(() => process.exit(1));
});
process.on("unhandledRejection", (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
console.error(`[server] 未处理的 Promise 拒绝: ${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
// down the entire service for it (Node's default behavior) isn't worth it — persist +
// log, then keep serving.
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Local date helpers (same convention as core's Trace date directories: local timezone
* yyyy-mm-dd). Shared by the usage_records.date aggregation key and stats windows
* (today / last 7 days / last 30 days).
*/
/** Format a time as a local `yyyy-mm-dd` (4-digit year, zero-padded 2-digit month/day). */
export function formatLocalDate(date: Date): string {
const year = date.getFullYear().toString().padStart(4, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Subtract N days from a local date (used for the start of the last-7-days / last-30-days windows). */
export function localDateMinusDays(date: Date, days: number): string {
const d = new Date(date);
d.setDate(d.getDate() - days);
return formatLocalDate(d);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Tool call approval: ApproveFn factory + pending
* approval registry.
*
* Approval mode semantics match the CLI (packages/cli/src/approval.ts):
* allow-all auto-approves; deny-all auto-denies; read-only allows read-only tools
* (permission==="r") and routes the rest to manual approval; always-ask routes
* everything to manual approval. Routing to manual approval registers a pending entry
* and pushes an `approval_request` via SSE, suspending until the frontend decides via
* `POST /approvals/:toolCallId`; no timeout — pending approvals are resolved to deny
* when the Task is interrupted (then proceeds through the abort flow).
*
* Every approval decision re-reads the current approval_mode (`getMode` reads the DB),
* so mode changes take effect immediately.
* Docs: /docs/tools § "Approval".
*/
import type { ApprovalMode } from "../api/types.js";
import type {
ApprovalDecision,
ApproveFn,
OmniMessage,
ToolCallPayload,
} from "@prismshadow/penguin-core";
export interface PendingApproval {
toolCall: OmniMessage<ToolCallPayload>;
origin?: string[];
}
interface PendingEntry extends PendingApproval {
resolve: (decision: ApprovalDecision) => void;
}
/** Pending approval registry (key = tool_call_id), one per Session runtime. */
export class ApprovalRegistry {
private readonly pending = new Map<string, PendingEntry>();
get size(): number {
return this.pending.size;
}
/** All currently pending approvals (for subscription replay). */
list(): PendingApproval[] {
return [...this.pending.values()].map(({ toolCall, origin }) => ({
toolCall,
...(origin !== undefined ? { origin } : {}),
}));
}
/** Register and wait for a decision. Re-registering the same id (defensive) resolves the old entry as deny. */
wait(toolCall: OmniMessage<ToolCallPayload>): Promise<ApprovalDecision> {
const id = toolCall.payload.tool_call_id;
this.pending.get(id)?.resolve("deny");
return new Promise<ApprovalDecision>((resolve) => {
const entry: PendingEntry = {
toolCall,
...(toolCall.origin !== undefined ? { origin: toolCall.origin } : {}),
resolve: (decision) => {
this.pending.delete(id);
resolve(decision);
},
};
this.pending.set(id, entry);
});
}
/** Submit a decision; returns false if not found (already decided/unknown). */
decide(toolCallId: string, decision: ApprovalDecision): boolean {
const entry = this.pending.get(toolCallId);
if (!entry) return false;
entry.resolve(decision);
return true;
}
/** Interruption convergence: resolve all pending approvals as deny. */
denyAll(): void {
for (const entry of [...this.pending.values()]) entry.resolve("deny");
}
}
/**
* Build the approve callback: re-reads the approval mode on every call; when routed to
* manual approval, registers a pending entry and suspends after pushing an
* `approval_request` server event via `publishRequest`.
*/
export function makeApprove(args: {
getMode: () => ApprovalMode;
toolPermission: (name: string) => "r" | "rw" | undefined;
registry: ApprovalRegistry;
publishRequest: (pending: PendingApproval) => void;
}): ApproveFn {
const { getMode, toolPermission, registry, publishRequest } = args;
const manual = (toolCall: OmniMessage<ToolCallPayload>): Promise<ApprovalDecision> => {
const promise = registry.wait(toolCall);
publishRequest({
toolCall,
...(toolCall.origin !== undefined ? { origin: toolCall.origin } : {}),
});
return promise;
};
return async (toolCall) => {
switch (getMode()) {
case "allow-all":
return "allow";
case "deny-all":
return "deny";
case "read-only":
// Auto-approve read-only tools; route read-write/unknown tools to manual approval (matches CLI semantics).
if (toolPermission(toolCall.payload.name) === "r") return "allow";
return manual(toolCall);
case "always-ask":
default:
return manual(toolCall);
}
};
}
+194
View File
@@ -0,0 +1,194 @@
/**
* SSE event channel.
*
* The Session channel and the user channel share this implementation:
* - Event id is an opaque string `<epoch>-<seq>`: epoch is a random short string
* generated when each Channel instance is created, seq is a monotonically increasing
* integer within the channel. epoch necessarily changes when the channel is
* recycled/recreated or the process restarts, so a stale Last-Event-ID always misses
* and falls through to resync — this prevents a silent false-hit event loss when the
* new epoch's event count happens to exceed the old id;
* - A bounded ring buffer (most recent 1000 entries or 2MB, whichever comes first,
* evicting the oldest on overflow) serves replay-on-reconnect via `Last-Event-ID`;
* an evicted/unknown id is handled by the caller sending `resync_required`;
* - Unicast (sendTo) is used for one-off replay at subscribe time (pending approvals /
* resync / hello): it consumes a seq number but doesn't enter the buffer or get
* broadcast — if that subscriber later reconnects with this id, the hit check is
* still safe (seq is monotonic).
*
* This module only handles event numbering / buffering / dispatch, not HTTP — SSE
* output is adapted at the routing layer.
* Docs: /docs/server-api § "Delivery Guarantees".
*/
import { randomUUID } from "node:crypto";
/** A numbered channel event; `id` is `<epoch>-<seq>`, `data` is serialized single-line JSON. */
export interface ChannelEvent {
id: string;
/** SSE event name; omitted (OmniMessage) means no `event:` line. */
event?: string;
data: string;
}
export type ChannelListener = (evt: ChannelEvent) => void;
export interface ChannelOptions {
maxBufferCount?: number;
maxBufferBytes?: number;
}
const DEFAULT_MAX_COUNT = 1000;
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
/** Buffered entry: seq is stored separately so hit checks never need to parse the string id. */
interface BufferedEvent {
seq: number;
evt: ChannelEvent;
}
export class Channel {
/** Channel epoch: generated at instance creation, prefixed onto event ids (necessarily changes after recycle/recreate or restart). */
readonly epoch: string = randomUUID().slice(0, 8);
private nextSeq = 1;
private buffer: BufferedEvent[] = [];
private bufferBytes = 0;
/** Max seq among evicted events (0 means never evicted): lower bound for hit checks. */
private lastEvictedSeq = 0;
private readonly listeners = new Set<ChannelListener>();
private readonly maxCount: number;
private readonly maxBytes: number;
/** Timestamp of last activity (publish/subscription change), used for idle-reclaim checks. */
lastActivityMs = Date.now();
constructor(opts: ChannelOptions = {}) {
this.maxCount = opts.maxBufferCount ?? DEFAULT_MAX_COUNT;
this.maxBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BYTES;
}
get subscriberCount(): number {
return this.listeners.size;
}
/** Broadcast an event: number it, buffer it (evicting the oldest), notify all subscribers. */
publish(data: unknown, event?: string): ChannelEvent {
const entry = this.makeEvent(data, event);
this.buffer.push(entry);
this.bufferBytes += entry.evt.data.length;
while (
this.buffer.length > 0 &&
(this.buffer.length > this.maxCount || this.bufferBytes > this.maxBytes)
) {
const evicted = this.buffer.shift()!;
this.bufferBytes -= evicted.evt.data.length;
this.lastEvictedSeq = Math.max(this.lastEvictedSeq, evicted.seq);
}
for (const listener of this.listeners) listener(entry.evt);
return entry.evt;
}
/** Unicast an event to a single subscriber: consumes a seq but doesn't buffer or broadcast (used for replay at subscribe time). */
sendTo(listener: ChannelListener, data: unknown, event?: string): ChannelEvent {
const entry = this.makeEvent(data, event);
listener(entry.evt);
return entry.evt;
}
subscribe(listener: ChannelListener): () => void {
this.listeners.add(listener);
this.lastActivityMs = Date.now();
return () => {
this.listeners.delete(listener);
this.lastActivityMs = Date.now();
};
}
/**
* Compute replay from a Last-Event-ID (`<epoch>-<seq>`): a mismatched epoch (channel
* recycled/recreated, process restarted, or malformed id) always misses; a matching
* epoch hits the buffer (if no events after that seq have been evicted and the seq was
* indeed assigned by this channel) and returns the buffered events after it; otherwise
* miss (the caller should send `resync_required` first).
*/
replayAfter(lastEventId: string): { hit: boolean; events: ChannelEvent[] } {
const sep = lastEventId.lastIndexOf("-");
if (sep <= 0) return { hit: false, events: [] };
const epoch = lastEventId.slice(0, sep);
const seq = Number.parseInt(lastEventId.slice(sep + 1), 10);
if (epoch !== this.epoch || !Number.isInteger(seq) || seq < 0) {
return { hit: false, events: [] };
}
const hit = seq >= this.lastEvictedSeq && seq < this.nextSeq;
if (!hit) return { hit: false, events: [] };
return { hit: true, events: this.buffer.filter((e) => e.seq > seq).map((e) => e.evt) };
}
private makeEvent(data: unknown, event?: string): BufferedEvent {
this.lastActivityMs = Date.now();
const serialized = typeof data === "string" ? data : JSON.stringify(data);
const seq = this.nextSeq++;
const evt: ChannelEvent = { id: `${this.epoch}-${seq}`, data: serialized };
if (event !== undefined) evt.event = event;
return { seq, evt };
}
}
const DEFAULT_IDLE_MS = 30 * 60 * 1000;
const SWEEP_INTERVAL_MS = 60 * 1000;
export interface ChannelHubOptions {
idleMs?: number;
/**
* Active check: keys for which this returns true are excluded from idle reclaim (app
* assembly injects `manager.statusOf(key) !== "idle"`, so a running/compacting
* Session channel is never reclaimed no matter how long since its last publish; a
* user channel key looks like `user:<id>` and is always considered active).
*/
isActive?: (key: string) => boolean;
}
/**
* Channel collection: lazily created by key (Session id or `user:<user_id>`);
* a channel whose Session is idle and has had no subscribers for over 30 minutes is
* reclaimed, releasing its buffer as well.
*/
export class ChannelHub {
private readonly channels = new Map<string, Channel>();
private readonly timer: NodeJS.Timeout;
private readonly idleMs: number;
private readonly isActive: (key: string) => boolean;
constructor(opts: ChannelHubOptions = {}) {
this.idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
this.isActive = opts.isActive ?? (() => false);
this.timer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
this.timer.unref?.();
}
get(key: string): Channel {
let ch = this.channels.get(key);
if (!ch) {
ch = new Channel();
this.channels.set(key, ch);
}
return ch;
}
peek(key: string): Channel | undefined {
return this.channels.get(key);
}
/** Reclaim idle channels (skips active Sessions: no reclaim even without a publish while awaiting approval); `now` is injectable for tests. */
sweep(now: number = Date.now()): void {
for (const [key, ch] of this.channels) {
if (this.isActive(key)) continue;
if (ch.subscriberCount === 0 && now - ch.lastActivityMs > this.idleMs) {
this.channels.delete(key);
}
}
}
dispose(): void {
clearInterval(this.timer);
this.channels.clear();
}
}
@@ -0,0 +1,161 @@
/**
* Error persistence: errors caught on the server are all
* written to error_records through here, for display on the stats dashboard. Shape
* mirrors usage-recorder — persist only raw facts, leave aggregation to query time.
*
* **The classification (kind) criterion is "does a human need to step in"**, not where
* the error originated:
*
* - `expected`: anticipated by the system, has a defined handling path, part of normal
* operation, no human needed — HTTP business errors (`HttpError`, mostly 4xx); LLM
* `timeout` / `malformed` (the engine already reconnects and retries); tool execution
* `failed` / `timeout` (the error is fed back to the model, and the Agent adjusts on
* its own).
* - `unexpected`: shouldn't happen, usually a bug or a config/environment fault,
* **needs a human** — internal errors converged to 500; process crashes; runtime
* errors escaping from background tasks (Session drive / usage persistence / title
* generation / subagent registration); LLM `failed` (not retryable: auth failure,
* invalid params, etc.).
* - User-initiated actions **are not errors** and are never recorded: request/tool
* `aborted` (user clicked "stop", or denied a tool).
*
* Determination: HTTP sources are inferred automatically from `HttpError` (preserving
* existing behavior); other sources must pass `kind` explicitly at the capture site.
* The frontend highlights unexpected by default; expected is still recorded without
* losing information.
*
* Sources cover HTTP, Session drive, LLM requests, Environment (tool execution), usage
* persistence, title generation, subagent registration, and process-level fallback;
* among these, `llm` / `environment` errors are not expressed via throw (core converges
* them into the message stream instead), and are fished out by stream-error-watcher from
* the Session output stream.
*
* **This recorder never throws**: it's hooked onto app.onError, and throwing from
* within it would turn error handling into infinite recursion; if persistence itself
* fails (disk full / DB already closed, etc.), it's fine to drop that one record.
*
* **Short-window dedup (DEDUP_WINDOW_MS)**: error storms are the norm — someone scanning
* the API produces a wall of 404s, or a tool fails repeatedly in a loop. Persisting each
* one both write-amplifies and floods the table, and makes the dashboard's "most recent
* 20" all the same error. So the same `(source, code, Project)` is persisted at most
* once per window; repeats within the window are **dropped outright** (not persisted);
* only an actual persist refreshes the timestamp, so a sustained storm leaves a steady
* one record per window instead of being suppressed indefinitely.
* **Tradeoff**: aggregate counts therefore **underestimate** — a storm of the same error
* only counts once, check the logs for true frequency; in exchange, a single error storm
* doesn't drown out error_records or the stats dashboard. The second line of defense is
* ErrorsRepo's capacity cap. The dedup table (lastSeen) must stay bounded: past
* DEDUP_KEYS_MAX, expired entries are cleared first, and if still over the limit the
* whole table is cleared — better to miss some dedup than let it grow unbounded across
* different error codes.
*/
import { formatLocalDate } from "../internal/dates.js";
import { HttpError } from "../http/errors.js";
import type { ErrorsRepo } from "../db/repos/errors.js";
/** Capture-site source (maps one-to-one to error_records.source). */
export type ErrorSource =
| "http"
| "session"
| "llm"
| "environment"
| "usage"
| "title"
| "subagent"
| "process"
| "schedule";
/** Error classification: see file header — the criterion is "does a human need to step in". */
export type ErrorKind = "expected" | "unexpected";
/** Attribution context (all optional: the login endpoint has no Project, and process-level fallback has no request at all). */
export interface ErrorContext {
projectId?: string;
agentId?: string;
sessionId?: string;
}
export interface ErrorRecordArgs {
source: ErrorSource;
/** The caught error (unknown: the value caught may not be an Error; failures from the message stream pass the reason text directly). */
err: unknown;
ctx?: ErrorContext;
/** Semantic code (required for non-HTTP sources, e.g. session_run_failed); defaults to HttpError.code. */
code?: string;
/** HTTP status code; leave empty for non-HTTP sources. */
status?: number;
/** Explicit classification (see file header); defaults to inferring from `HttpError` — HTTP sources rely on this, other sources should pass it explicitly. */
kind?: ErrorKind;
}
/** Message truncation length (keep only a readable summary; the full stack is still logged). */
export const MESSAGE_MAX = 500;
/** Short-window dedup window: the same (source, code, Project) is persisted at most once per window (see the file header's tradeoff). */
export const DEDUP_WINDOW_MS = 2000;
/** Cap on dedup table keys (bounded; over the limit, expired entries are cleared first, and if still over, the whole table is cleared). */
export const DEDUP_KEYS_MAX = 1000;
function messageOf(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
return raw.length > MESSAGE_MAX ? raw.slice(0, MESSAGE_MAX) : raw;
}
export class ErrorRecorder {
/** Dedup table: `source \0 code \0 projectId` → timestamp of the last **persist** (see file header). */
private readonly lastSeen = new Map<string, number>();
constructor(
private readonly errors: ErrorsRepo,
private readonly now: () => Date = () => new Date(),
) {}
/** Record an error (synchronous, fails silently; same-window duplicates are dropped outright, see file header). */
record(args: ErrorRecordArgs): void {
try {
const http = args.err instanceof HttpError ? args.err : null;
const now = this.now();
const projectId = args.ctx?.projectId ?? null;
const code = args.code ?? http?.code ?? "internal";
// Short-window dedup: coarse-grained to "same kind of error for the same Project"; repeats within the window aren't persisted.
if (this.deduped(`${args.source}\0${code}\0${projectId ?? ""}`, now.getTime())) return;
this.errors.insert({
ts: now.toISOString(),
date: formatLocalDate(now),
projectId,
agentId: args.ctx?.agentId ?? null,
sessionId: args.ctx?.sessionId ?? null,
source: args.source,
// Explicit classification takes priority; otherwise infer from HttpError (business error = expected, else unexpected).
kind: args.kind ?? (http ? "expected" : "unexpected"),
code,
// Unexpected errors from HTTP sources are converged to 500 externally (matches handleError's response).
status: args.status ?? http?.status ?? (args.source === "http" ? 500 : null),
message: messageOf(args.err),
});
} catch {
// See file header: if the recorder itself errors, dropping this one record is the only option — never rethrow.
}
}
/** true if a same-kind error was already recorded within the window (drop it); otherwise register this persist timestamp and keep the dedup table bounded. */
private deduped(key: string, nowMs: number): boolean {
const last = this.lastSeen.get(key);
if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) return true;
this.lastSeen.set(key, nowMs);
if (this.lastSeen.size > DEDUP_KEYS_MAX) this.evict(nowMs);
return false;
}
/** Keep the dedup table bounded (see file header): clear expired entries first; if still over the limit (hundreds/thousands of distinct error codes erupting at once), clear it entirely. */
private evict(nowMs: number): void {
for (const [key, at] of this.lastSeen) {
if (nowMs - at >= DEDUP_WINDOW_MS) this.lastSeen.delete(key);
}
if (this.lastSeen.size > DEDUP_KEYS_MAX) this.lastSeen.clear();
}
}
/** Minimal dependency a capture site needs on the recorder (tests inject a fake; structurally matches SessionManager's UsageRecorderLike). */
export type ErrorSink = Pick<ErrorRecorder, "record">;
@@ -0,0 +1,214 @@
/**
* Schedule file parsing, validation, and trigger-time computation.
*
* `agent_state/schedule/<name>.toml` is declarative intent; the system never writes it
* back. This module does pure parsing and pure time math only: an invalid file returns
* an error (the scheduler skips it and records the error); runtime state (fired /
* missed / disabled) doesn't live here — it belongs to SQLite (db/repos/schedules.ts).
* Docs: /docs/configuration § "Schedules".
*/
import { parse as parseToml } from "smol-toml";
/** `period` lower bound: below 5 minutes is treated as an invalid file (guards against runaway high-frequency tasks). */
export const MIN_PERIOD_MS = 5 * 60_000;
/** A parsed schedule definition (the filename minus `.toml` is its identity). */
export interface ScheduleDefinition {
name: string;
/** The Prompt to send (required). */
prompt: string;
/** Enabled switch; disabled by default. */
enabled: boolean;
/** Original text of the first trigger time (for API echo, preserving the written form). */
startAt: string;
/** First trigger time (epoch ms). */
startAtMs: number;
/** Original text of the end time. */
endAt?: string;
/** Original text of the trigger period (e.g. `30m`, for API echo); undefined means a one-shot task. */
period?: string;
/** Trigger period (ms); undefined means a one-shot task. */
periodMs?: number;
/** End time (epoch ms); no more triggers once past it. */
endAtMs?: number;
/** The target Session to bind to; defaults to creating a new Session each time. */
sessionId?: string;
/** Workspace for new-Session mode (same semantics as manually starting a session; auto-creates a temp directory if unspecified). */
workspace?: string;
/** Model for new-Session mode (upstream id, paired with provider; defaults to the Project's default reference). */
modelId?: string;
/**
* Vendor grouping for `model_id` (paired reference); when omitted, resolved per
* resolveModelRef semantics — whether the reference is resolvable is validated by the
* caller against config at reconciliation/save time (this module does pure parsing
* and never touches config).
*/
provider?: string;
}
export type ScheduleParseResult =
{ ok: true; def: ScheduleDefinition } | { ok: false; error: string };
/** Parse a fixed interval in `30m` / `12h` / `7d` form; returns null if invalid. */
export function parsePeriod(raw: string): number | null {
const m = /^(\d+)([mhd])$/.exec(raw.trim());
if (!m) return null;
const n = Number(m[1]);
if (!Number.isInteger(n) || n <= 0) return null;
const unit = m[2] === "m" ? 60_000 : m[2] === "h" ? 3_600_000 : 86_400_000;
return n * unit;
}
/** Parse an ISO 8601 instant into epoch ms plus the original text for echo; returns null if invalid (smol-toml's date values are also accepted). */
function parseInstant(value: unknown): { ms: number; raw: string } | null {
if (value instanceof Date) {
const ms = value.getTime();
return Number.isNaN(ms) ? null : { ms, raw: value.toISOString() };
}
if (typeof value !== "string") return null;
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : { ms, raw: value };
}
/**
* Parse and validate a schedule file. A field with the wrong type invalidates the whole
* file (the baseline for hand-edit tolerance is to never let bad config reach the
* scheduler); unknown keys are ignored (forward compatibility).
*/
export function parseScheduleFile(name: string, raw: string): ScheduleParseResult {
let parsed: unknown;
try {
parsed = parseToml(raw);
} catch (err) {
return {
ok: false,
error: `TOML 解析失败:${err instanceof Error ? err.message : String(err)}`,
};
}
if (parsed === null || typeof parsed !== "object")
return { ok: false, error: "内容不是 TOML 表" };
const t = parsed as Record<string, unknown>;
const prompt = t["prompt"];
if (typeof prompt !== "string" || prompt.trim() === "") {
return { ok: false, error: "缺少必填字段 prompt" };
}
const enabled = t["enabled"] === undefined ? false : t["enabled"];
if (typeof enabled !== "boolean") return { ok: false, error: "enabled 必须是布尔值" };
const startAt = parseInstant(t["start_at"]);
if (startAt === null) return { ok: false, error: "start_at 缺失或不是合法的 ISO 8601 时刻" };
let period: string | undefined;
let periodMs: number | undefined;
if (t["period"] !== undefined) {
if (typeof t["period"] !== "string") return { ok: false, error: "period 必须是字符串" };
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" };
period = t["period"].trim();
periodMs = ms;
}
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" };
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 必须是非空字符串" };
}
sessionId = t["session_id"];
}
let workspace: string | undefined;
if (t["workspace"] !== undefined) {
if (typeof t["workspace"] !== "string" || t["workspace"] === "") {
return { ok: false, error: "workspace 必须是非空字符串" };
}
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 必须是非空字符串" };
}
modelId = t["model_id"];
}
let provider: string | undefined;
if (t["provider"] !== undefined) {
if (typeof t["provider"] !== "string" || t["provider"] === "") {
return { ok: false, error: "provider 必须是非空字符串" };
}
provider = t["provider"];
}
if (provider !== undefined && modelId === undefined) {
return { ok: false, error: "provider 仅与 model_id 成对使用(模型引用须成对给出)" };
}
if (
sessionId !== undefined &&
(workspace !== undefined || modelId !== undefined || provider !== undefined)
) {
return {
ok: false,
error: "目标二选一:workspace 与 provider / model_id 仅用于新建 Session 模式",
};
}
return {
ok: true,
def: {
name,
prompt,
enabled,
startAt: startAt.raw,
startAtMs: startAt.ms,
...(period !== undefined ? { period } : {}),
...(periodMs !== undefined ? { periodMs } : {}),
...(endAt !== undefined ? { endAt: endAt.raw, endAtMs: endAt.ms } : {}),
...(sessionId !== undefined ? { sessionId } : {}),
...(workspace !== undefined ? { workspace } : {}),
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
},
};
}
/**
* Step from `start_at` by `period` and return the most recent scheduled time not later
* than `nowMs`; null if `start_at` hasn't been reached yet. A one-shot task's only slot
* is `start_at` itself.
*/
export function latestSlotAt(def: ScheduleDefinition, nowMs: number): number | null {
if (nowMs < def.startAtMs) return null;
if (def.periodMs === undefined) return def.startAtMs;
const k = Math.floor((nowMs - def.startAtMs) / def.periodMs);
return def.startAtMs + k * def.periodMs;
}
/** Whether a scheduled slot still falls within the `[start_at, end_at]` window (always true if there's no end_at). */
export function slotInWindow(def: ScheduleDefinition, slotMs: number): boolean {
return def.endAtMs === undefined || slotMs <= def.endAtMs;
}
/**
* The next scheduled time strictly after `nowMs` (used to display "next trigger");
* for a one-shot task this only has a value while start_at hasn't been reached, and
* returns null once past end_at.
*/
export function nextSlotAfter(def: ScheduleDefinition, nowMs: number): number | null {
let next: number;
if (nowMs < def.startAtMs) {
next = def.startAtMs;
} else if (def.periodMs === undefined) {
return null;
} else {
const k = Math.floor((nowMs - def.startAtMs) / def.periodMs) + 1;
next = def.startAtMs + k * def.periodMs;
}
return slotInWindow(def, next) ? next : null;
}
@@ -0,0 +1,140 @@
/**
* Schedule file access: `agent_state/schedule/<name>.toml`, where
* the filename (a semantic name) is the identity. Reads are fault-tolerant (an invalid
* file is recorded as an error and skipped by the caller); writes only go through the
* API routes (the system never rewrites existing file content — PUT is a full-file
* replacement expressing user intent).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { stringify as stringifyToml } from "smol-toml";
import { loadProjectConfig, resolveModelRef, scheduleDir } from "@prismshadow/penguin-core";
import type { ScheduleDefinition } from "./schedule-file.js";
import { parseScheduleFile, type ScheduleParseResult } from "./schedule-file.js";
export interface ScheduleFileEntry {
name: string;
raw: string;
parsed: ScheduleParseResult;
}
/** List all schedule files for this Agent (a missing directory is treated as empty). */
export async function listScheduleFiles(
root: string,
projectId: string,
agentId: string,
): Promise<ScheduleFileEntry[]> {
const dir = scheduleDir(root, projectId, agentId);
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
return [];
}
const entries: ScheduleFileEntry[] = [];
for (const file of names.sort()) {
if (!file.endsWith(".toml")) continue;
const name = file.slice(0, -".toml".length);
let raw: string;
try {
raw = await fs.readFile(path.join(dir, file), "utf8");
} catch {
continue; // Deleted during reconciliation: revisit next round.
}
entries.push({ name, raw, parsed: parseScheduleFile(name, raw) });
}
return entries;
}
export async function readScheduleFile(
root: string,
projectId: string,
agentId: string,
name: string,
): Promise<ScheduleFileEntry | null> {
const file = path.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
try {
const raw = await fs.readFile(file, "utf8");
return { name, raw, parsed: parseScheduleFile(name, raw) };
} catch {
return null;
}
}
/** Serialize API fields into file content (validation uniformly goes through parseScheduleFile, avoiding two sets of rules). */
export function serializeSchedule(fields: {
prompt: string;
enabled: boolean;
startAt: string;
period?: string;
endAt?: string;
sessionId?: string;
workspace?: string;
modelId?: string;
provider?: string;
}): string {
const table: Record<string, unknown> = {
prompt: fields.prompt,
enabled: fields.enabled,
start_at: fields.startAt,
...(fields.period !== undefined ? { period: fields.period } : {}),
...(fields.endAt !== undefined ? { end_at: fields.endAt } : {}),
...(fields.sessionId !== undefined ? { session_id: fields.sessionId } : {}),
...(fields.workspace !== undefined ? { workspace: fields.workspace } : {}),
...(fields.provider !== undefined ? { provider: fields.provider } : {}),
...(fields.modelId !== undefined ? { model_id: fields.modelId } : {}),
};
return `${stringifyToml(table)}\n`;
}
/**
* Resolvability check for a schedule's model reference (shared by save and
* reconciliation): when the definition has `model_id`, it's
* resolved against Project config per resolveModelRef semantics — omitting provider is
* only resolvable when model_id matches exactly one entry globally; zero hits or
* ambiguity means unresolvable. Returns an error message (unresolvable / config read
* failure), or null if resolvable (or no model reference at all).
*/
export async function validateScheduleModelRef(
root: string,
projectId: string,
def: Pick<ScheduleDefinition, "modelId" | "provider">,
): Promise<string | null> {
if (def.modelId === undefined) return null;
try {
const cfg = await loadProjectConfig(root, projectId);
resolveModelRef(cfg, def.modelId, def.provider);
return null;
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
}
/** Write a schedule file to disk (full-file replacement for POST/PUT). */
export async function writeScheduleFile(
root: string,
projectId: string,
agentId: string,
name: string,
raw: string,
): Promise<void> {
const dir = scheduleDir(root, projectId, agentId);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${name}.toml`), raw, "utf8");
}
/** Delete a schedule file; returns false if it doesn't exist. */
export async function deleteScheduleFile(
root: string,
projectId: string,
agentId: string,
name: string,
): Promise<boolean> {
const file = path.join(scheduleDir(root, projectId, agentId), `${name}.toml`);
try {
await fs.unlink(file);
return true;
} catch {
return false;
}
}
Binary file not shown.
@@ -0,0 +1,841 @@
/**
* Active Session runtime.
*
* Responsibilities:
* - get-or-resume-or-heal: use it directly on an active-table hit; with a Trace,
* recover via `agent.resumeSession`; a stale Session that was created but never run
* and survived a process restart (no Trace) **self-heals** — recreated via
* createSession using the index row's workspace/modelId, yielding a new session_id
* and updating the index's primary key; the Task response body always returns the
* current actual id;
* - Per-Session mutual exclusion: only one Task/compaction may be in progress at a
* time;
* - run/compact drive: consumes the output stream in the background, publishing each
* message to the SSE channel and handing it to usage-recorder for persistence;
* on completion (including errors) resets to idle and pushes a `task_state` server
* event;
* - Approval registration and interrupt convergence: each approval decision re-reads
* approval_mode from the DB (takes effect immediately); an interrupt first
* converges pending approvals to deny, then aborts.
*
* The underlying implementation of get-or-resume-or-heal is injected via
* `SessionLoader`: production uses the core SDK (createCoreSessionLoader), tests inject
* a fake Session (issuing no real LLM requests).
*/
import fs from "node:fs/promises";
import path from "node:path";
import {
createAgent,
findLatestTraceFile,
isSessionMeta,
tracesDir,
} from "@prismshadow/penguin-core";
import type {
ApproveFn,
CompactAvailability,
OmniMessage,
SessionMetaPayload,
SessionTitleResult,
TextPayload,
} from "@prismshadow/penguin-core";
import type { ServerEvent, SessionStatus } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
import { ApprovalRegistry, makeApprove } from "./approvals.js";
import type { PendingApproval } from "./approvals.js";
import type { ChannelHub } from "./channel.js";
import type { ErrorSink } from "./error-recorder.js";
import { StreamErrorWatcher } from "./stream-error-watcher.js";
import type { TitleNotifier } from "./title-generator.js";
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: "上下文刚压缩过,此后还没有新的对话,无需再次压缩。",
};
return new HttpError(409, "nothing_to_compact", messages[why]);
}
/** Minimal interface for a runtime Session (satisfied by core Session; tests may inject a fake implementation). */
export interface RuntimeSession {
readonly sessionId: string;
run(
newMessages: OmniMessage[],
opts: { approve: ApproveFn; signal: AbortSignal },
): AsyncGenerator<OmniMessage>;
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
compactability(): CompactAvailability;
toolPermission(name: string): "r" | "rw" | undefined;
/**
* Out-of-band one-shot request for title generation (core `Session.generateTitle`,
* writes no history/Trace). Material defaults to what the Session collects itself
* (the first Task's text gathered during run); `material` overrides this for
* subagents.
*/
generateTitle(args?: {
material?: { userText: string; assistantText: string };
signal?: AbortSignal;
}): Promise<SessionTitleResult>;
}
/** The underlying loader behind get-or-resume-or-heal. */
export interface SessionLoader {
/**
* Load a runtime Session from an index row: recover (with a Trace) or self-heal
* rebuild (no Trace, session_id will change). Throws HttpError(409) for unrecoverable
* cases such as a missing Workspace.
*/
load(row: SessionRow): Promise<RuntimeSession>;
}
/** Production loader: the core SDK's resumeSession / createSession. */
export function createCoreSessionLoader(root: string): SessionLoader {
return {
async load(row: SessionRow): Promise<RuntimeSession> {
const agent = await createAgent({
root,
projectId: row.projectId,
agentId: row.agentId,
});
const located = await findLatestTraceFile(
tracesDir(root, row.projectId, row.agentId),
row.sessionId,
);
if (located) {
// With a Trace: rebuild via "Session Recovery" (history injected via setHistory,
// carrying over any residual state).
// core's recognizable recovery failures (Workspace deleted / Model removed from
// config / Trace missing session_meta, etc.) are converged to 409, preserving
// the original message rather than bubbling up as 500.
try {
return await agent.resumeSession({ sessionId: row.sessionId });
} catch (err) {
// The credential key was deleted after the Session was created: only caught
// here at recovery time; give the same actionable message.
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
throw toUnrecoverableError(err);
}
}
// No Trace (created but never run, and the process has restarted since): self-heal
// rebuild. A missing Workspace → 409.
try {
const stat = await fs.stat(row.workspace);
if (!stat.isDirectory()) throw new Error("not a directory");
} catch {
throw new HttpError(
409,
"workspace_missing",
`该 Session 的 Workspace 已不存在:${row.workspace},无法继续。请新建 Session。`,
);
}
try {
return await agent.createSession({
workspaceDir: row.workspace,
modelId: row.modelId,
provider: row.provider,
});
} catch (err) {
if (isMissingCredential(err)) throw modelCredentialMissing(row.modelId);
throw toUnrecoverableError(err);
}
},
};
}
/** A plain Error thrown by core recovery/self-heal rebuild → 409 (preserving the original, actionable message). */
function toUnrecoverableError(err: unknown): HttpError {
if (err instanceof HttpError) return err;
return new HttpError(
409,
"session_unrecoverable",
err instanceof Error ? err.message : String(err),
);
}
export interface UsageRecorderLike {
record(ctx: UsageContext, msg: OmniMessage): Promise<void>;
}
export interface SessionManagerDeps {
sessions: SessionsRepo;
channels: ChannelHub;
loader: SessionLoader;
recorder: UsageRecorderLike;
/** Automatic Session title generation (optional: not injected in tests or when disabled). */
titles?: TitleNotifier;
/** Error persistence (optional: without it, only logs — same as before this was wired up). */
errors?: ErrorSink;
log?: (line: string) => void;
}
/** Active-table entry: a loaded runtime Session plus its running state. */
interface RuntimeEntry {
sessionId: string;
projectId: string;
agentId: string;
/** Vendor grouping for the Session's model (paired with modelId to form a model reference). */
provider: string;
modelId: string;
session: RuntimeSession;
status: SessionStatus;
approvals: ApprovalRegistry;
abort: AbortController | null;
/** The in-flight drive Promise (awaited during graceful shutdown). */
running: Promise<void> | null;
/** Timestamp of last activity (refreshed on load / status flip / drive completion), used for idle-eviction checks. */
lastActivityMs: number;
}
/** Active-table idle eviction: same convention as the SSE channel (an idle entry with no activity for 30 minutes releases its memory). */
const ENTRY_IDLE_MS = 30 * 60 * 1000;
const ENTRY_SWEEP_INTERVAL_MS = 60 * 1000;
/** Cap on collected model text for title material (accumulation stops beyond this; the generator side also truncates further). */
const TITLE_EXCERPT_LIMIT = 4000;
/** Composite Agent key (used as a Set key, avoiding projectId/agentId concatenation ambiguity). */
function agentKey(projectId: string, agentId: string): string {
return `${projectId}\0${agentId}`;
}
/** If msg is a run_subagent tool call carrying a `prompt`, return its id and prompt (for use as the subagent's title); otherwise null. */
function runSubagentCall(msg: OmniMessage): { toolCallId: string; prompt: string } | null {
const p = msg.payload as {
type?: string;
name?: string;
arguments?: string;
tool_call_id?: string;
};
if (msg.type !== "model_msg" || p.type !== "tool_call" || p.name !== "run_subagent") return null;
if (typeof p.arguments !== "string" || typeof p.tool_call_id !== "string") return null;
try {
const args = JSON.parse(p.arguments) as { prompt?: unknown };
if (typeof args.prompt !== "string" || !args.prompt.trim()) return null;
return { toolCallId: p.tool_call_id, prompt: args.prompt };
} catch {
return null; // Arguments were truncated/malformed: this call is doomed, no subagent will result
}
}
/** The denied tool_call_id (approval_decision with decision ≠ allow); otherwise null. */
function deniedToolCallId(msg: OmniMessage): string | null {
const p = msg.payload as { type?: string; decision?: string; tool_call_id?: string };
if (msg.type !== "event_msg" || p.type !== "approval_decision") return null;
if (p.decision === "allow" || typeof p.tool_call_id !== "string") return null;
return p.tool_call_id;
}
/** The tool_call_id of a parent-level tool call that has settled (a complete tool_call_output); otherwise null. */
function settledToolCallId(msg: OmniMessage): string | null {
const p = msg.payload as { type?: string; tool_call_id?: string };
if (msg.type !== "model_msg" || p.type !== "tool_call_output") return null;
return typeof p.tool_call_id === "string" ? p.tool_call_id : null;
}
/** A subagent registered during this run, plus its title material. */
interface ChildSession {
sessionId: string;
agentId: string;
modelId: string;
/** The prompt of the run_subagent call that spawned it (user material for title generation, and the fallback title). */
prompt: string;
/** The model text the subagent itself produced (assistant material for title generation). */
assistantExcerpt: string;
}
/** Predicate for a plain-text message on the main session (no origin): title material is drawn only from user/model text. */
function isPlainText(role: "user" | "assistant") {
return (msg: OmniMessage): msg is OmniMessage<TextPayload> => {
const payload = msg.payload as { type?: string; role?: string };
return (
msg.type === "model_msg" &&
payload.type === "text" &&
payload.role === role &&
(!msg.origin || msg.origin.length === 0)
);
};
}
/** For a nested message, the owning Session (end of the origin chain) and text of the model reply; null if it isn't model text. */
function nestedAssistantText(msg: OmniMessage): { sessionId: string; text: string } | null {
const p = msg.payload as { type?: string; role?: string; text?: string };
if (msg.type !== "model_msg" || p.type !== "text" || p.role !== "assistant") return null;
if (!msg.origin || msg.origin.length === 0 || typeof p.text !== "string") return null;
return { sessionId: msg.origin[msg.origin.length - 1]!, text: p.text };
}
export class SessionManager {
private readonly entries = new Map<string, RuntimeEntry>();
/** Per-Session mutex (serializes get-or-load and status flips); auto-cleaned once the chain drains. */
private readonly locks = new Map<string, Promise<unknown>>();
private readonly log: (line: string) => void;
/** Graceful-shutdown flag: once set, new Tasks/compactions are rejected (503). */
private closed = false;
/** Agents currently being deleted (key = agentKey): new Tasks/compactions are always rejected with 409 during this window. */
private readonly deletingAgents = new Set<string>();
/** Sessions currently being deleted (guards against the entry/Trace file being rebuilt and reviving it inside the deletion race window). */
private readonly deletingSessions = new Set<string>();
private readonly sweepTimer: NodeJS.Timeout;
constructor(private readonly deps: SessionManagerDeps) {
this.log = deps.log ?? ((line) => console.error(line));
this.sweepTimer = setInterval(() => this.sweepIdle(), ENTRY_SWEEP_INTERVAL_MS);
this.sweepTimer.unref?.();
}
// —— Query surface (used by Session listing / Agent active-count / SSE subscription replay) ——
statusOf(sessionId: string): SessionStatus {
return this.entries.get(sessionId)?.status ?? "idle";
}
pendingApprovalCount(sessionId: string): number {
return this.entries.get(sessionId)?.approvals.size ?? 0;
}
pendingApprovals(sessionId: string): PendingApproval[] {
return this.entries.get(sessionId)?.approvals.list() ?? [];
}
/** Number of Sessions for this Agent that are currently running / compacting. */
activeCountForAgent(projectId: string, agentId: string): number {
let n = 0;
for (const e of this.entries.values()) {
if (e.projectId === projectId && e.agentId === agentId && e.status !== "idle") n++;
}
return n;
}
/** Add a newly created Session to the active table (status idle), avoiding a redundant load on the next Task. */
adopt(row: SessionRow, session: RuntimeSession): void {
this.entries.set(row.sessionId, {
sessionId: row.sessionId,
projectId: row.projectId,
agentId: row.agentId,
provider: row.provider,
modelId: row.modelId,
session,
status: "idle",
approvals: new ApprovalRegistry(),
abort: null,
running: null,
lastActivityMs: Date.now(),
});
}
// —— Task / compaction drive ——
/**
* Start a Task: get-or-load → 409
* mutual-exclusion check → publish the input messages first → drive run in the
* background. Returns the current actual session_id (the new id after self-heal).
*/
async startTask(sessionId: string, input: OmniMessage[]): Promise<{ sessionId: string }> {
return this.withLock(sessionId, async () => {
this.assertOpen();
this.assertAgentNotDeleting(sessionId);
this.assertSessionNotDeleting(sessionId);
const entry = await this.ensureEntry(sessionId);
this.assertIdle(entry);
const channel = this.deps.channels.get(entry.sessionId);
const ac = new AbortController();
entry.status = "running";
entry.abort = ac;
entry.lastActivityMs = Date.now();
// Publish the input messages first (visible to other subscribers; the Trace is
// persisted by the SDK), then flip the running status.
for (const msg of input) channel.publish(msg);
this.publishState(entry, "running");
const approve = makeApprove({
// Re-reads approval_mode from the DB on every decision (a PATCH takes effect immediately).
getMode: () => this.deps.sessions.findById(entry.sessionId)?.approvalMode ?? "always-ask",
toolPermission: (name) => entry.session.toolPermission(name),
registry: entry.approvals,
publishRequest: (pending) =>
this.publishEvent(entry, {
type: "approval_request",
toolCall: pending.toolCall,
...(pending.origin !== undefined ? { origin: pending.origin } : {}),
}),
});
const gen = entry.session.run(input, { approve, signal: ac.signal });
// Title material is collected by the core Session itself during run; here we only
// keep this call's input user text, used both as the "material present → attempt
// generation" criterion and as the fallback title source if the LLM call fails.
const userExcerpt = input
.filter(isPlainText("user"))
.map((m) => m.payload.text)
.join("\n");
entry.running = this.drive(entry, gen, { userExcerpt });
return { sessionId: entry.sessionId };
});
}
/** Manually compact the context: 409 if already running; compaction output also flows into the SSE channel. */
async startCompact(sessionId: string): Promise<{ sessionId: string }> {
return this.withLock(sessionId, async () => {
this.assertOpen();
this.assertAgentNotDeleting(sessionId);
this.assertSessionNotDeleting(sessionId);
const entry = await this.ensureEntry(sessionId);
this.assertIdle(entry);
// When there's nothing to compact, core's compact() yields no messages at all: we
// can't just return 202 and walk away, or the frontend would wait forever for a
// compaction banner that never comes (this is exactly the "/compact does nothing
// after an interrupt" complaint). Reject explicitly, and **say why** clearly —
// "just compacted" and "haven't talked yet" share the same internal state
// (sessionTurns === 0), but are two completely different messages to the user:
// telling someone who just compacted that there's "no completed conversation turn
// yet" tells them nothing.
const why = entry.session.compactability();
if (why !== "ok") throw compactUnavailable(why);
const ac = new AbortController();
entry.status = "compacting";
entry.abort = ac;
entry.lastActivityMs = Date.now();
this.publishState(entry, "compacting");
const gen = entry.session.compact({ signal: ac.signal });
entry.running = this.drive(entry, gen);
return { sessionId: entry.sessionId };
});
}
/** Submit an approval decision; returns false if the pending approval doesn't exist (already decided/unknown). */
decideApproval(sessionId: string, toolCallId: string, decision: "allow" | "deny"): boolean {
const entry = this.entries.get(sessionId);
if (!entry) return false;
return entry.approvals.decide(toolCallId, decision);
}
/**
* Interrupt the current Task/compaction: pending approvals converge to deny first,
* then the AbortSignal fires. Returns false if nothing is in progress (the route
* treats this as a 204 no-op).
*/
abortTask(sessionId: string): boolean {
const entry = this.entries.get(sessionId);
if (!entry || !entry.abort) return false;
entry.approvals.denyAll();
entry.abort.abort();
return true;
}
/**
* Before deleting a Project, converge all its active runs and clear them out of the
* active table. Returns the in-flight drive Promises of the affected entries: the
* caller (deleteProject) should await them before removing the directory, so that
* interrupt-cleanup Trace writes don't recreate the directory after deletion.
*/
abortProject(projectId: string): Promise<void>[] {
const runnings: Promise<void>[] = [];
for (const [key, entry] of [...this.entries]) {
if (entry.projectId !== projectId) continue;
entry.approvals.denyAll();
entry.abort?.abort();
if (entry.running) runnings.push(entry.running);
this.entries.delete(key);
}
return runnings;
}
/**
* Before deleting an Agent, converge all its active runs and clear them out of the
* active table (same semantics as abortProject). Also marks this Agent as "being
* deleted": new Tasks/compactions entering during the deletion process are always
* rejected with 409 (assertAgentNotDeleting), closing the race window where a new
* task recreates the directory and revives an already-deleted Agent between the
* abortAgent snapshot and the directory removal. The caller must call
* endAgentDeletion once deletion finishes (success or failure).
*/
beginAgentDeletion(projectId: string, agentId: string): Promise<void>[] {
this.deletingAgents.add(agentKey(projectId, agentId));
const runnings: Promise<void>[] = [];
for (const [key, entry] of [...this.entries]) {
if (entry.projectId !== projectId || entry.agentId !== agentId) continue;
entry.approvals.denyAll();
entry.abort?.abort();
if (entry.running) runnings.push(entry.running);
this.entries.delete(key);
}
return runnings;
}
endAgentDeletion(projectId: string, agentId: string): void {
this.deletingAgents.delete(agentKey(projectId, agentId));
}
/**
* Before deleting a single Session, converge its active run and clear it out of the
* active table (same semantics as beginAgentDeletion). Also marks this Session as
* "being deleted": new Tasks/compactions entering during the deletion process are
* always rejected with 409 (assertSessionNotDeleting), closing the race window where
* a new task recreates the entry and Trace file, reviving an already-deleted Session
* between the abort snapshot and the file removal. The caller must call
* endSessionDeletion once deletion finishes (success or failure). Returns the
* in-flight drive Promise: the caller should await it before deleting the Trace file,
* so cleanup writes don't recreate the file.
*/
beginSessionDeletion(sessionId: string): Promise<void>[] {
this.deletingSessions.add(sessionId);
const entry = this.entries.get(sessionId);
if (!entry) return [];
entry.approvals.denyAll();
entry.abort?.abort();
this.entries.delete(sessionId);
return entry.running ? [entry.running] : [];
}
endSessionDeletion(sessionId: string): void {
this.deletingSessions.delete(sessionId);
}
/** Graceful shutdown: reject new tasks (503), interrupt all active runs, and wait for them to finish (default ≤5s). */
async shutdown(timeoutMs = 5000): Promise<void> {
this.closed = true;
clearInterval(this.sweepTimer);
const pending: Promise<void>[] = [];
for (const entry of this.entries.values()) {
if (!entry.abort) continue;
entry.approvals.denyAll();
entry.abort.abort();
if (entry.running) pending.push(entry.running);
}
if (pending.length === 0) return;
await Promise.race([
Promise.allSettled(pending).then(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
]);
}
/**
* Active-table idle eviction: removes entries that are idle (idle status, no pending
* approvals, no in-flight drive) and have been inactive past the timeout, releasing
* the core Session's full in-memory history. This is purely memory reclamation: the
* next access re-resumes via the loader, so correctness is unaffected. Lock-table
* entries are auto-cleaned by withLock once their chain drains (including leftover
* entries under the old id after self-heal). `now` / `idleMs` are injectable for
* tests and timers.
*/
sweepIdle(now: number = Date.now(), idleMs: number = ENTRY_IDLE_MS): void {
for (const [key, entry] of this.entries) {
if (entry.status !== "idle" || entry.approvals.size !== 0 || entry.running !== null) continue;
if (now - entry.lastActivityMs <= idleMs) continue;
this.entries.delete(key);
}
}
// —— Internal ——
private assertOpen(): void {
if (this.closed) {
throw new HttpError(503, "shutting_down", "服务端正在关停,暂不接受新任务。");
}
}
/** The Agent owning this Session is being deleted → 409 (guards against directory recreation inside the deletion race window). */
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 正在删除,暂不接受新任务。");
}
}
/** 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 正在删除,暂不接受新任务。");
}
}
private assertIdle(entry: RuntimeEntry): void {
if (entry.status === "running") {
throw new HttpError(409, "task_in_progress", "该 Session 已有进行中的 Task。");
}
if (entry.status === "compacting") {
throw new HttpError(409, "compacting", "该 Session 正在压缩上下文,暂不接受新输入。");
}
}
/** get-or-resume-or-heal: use directly on an active-table hit; otherwise load via the loader, updating the index's primary key on self-heal. */
private async ensureEntry(sessionId: string): Promise<RuntimeEntry> {
const existing = this.entries.get(sessionId);
if (existing) return existing;
const row = this.deps.sessions.findById(sessionId);
if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
}
const session = await this.deps.loader.load(row);
// The Session/Agent was marked for deletion while loading: discard the load result,
// don't rebuild the entry (avoids reviving an orphaned Trace).
this.assertSessionNotDeleting(row.sessionId);
this.assertAgentNotDeleting(row.sessionId);
let currentId = row.sessionId;
if (session.sessionId !== row.sessionId) {
// Self-heal produced a new session_id: update the index's primary key; the SSE
// channel and pending state are naturally empty for it.
this.deps.sessions.replaceId(row.sessionId, session.sessionId);
currentId = session.sessionId;
}
const entry: RuntimeEntry = {
sessionId: currentId,
projectId: row.projectId,
agentId: row.agentId,
provider: row.provider,
modelId: row.modelId,
session,
status: "idle",
approvals: new ApprovalRegistry(),
abort: null,
running: null,
lastActivityMs: Date.now(),
};
this.entries.set(currentId, entry);
return entry;
}
/**
* Drive the output stream in the background: publish each message + persist usage +
* persist LLM/tool errors; on completion (including errors) resets to idle and pushes
* the status. `titleSource` is passed only for Task runs (compaction doesn't generate
* a title): it collects model text for automatic title generation.
*/
private async drive(
entry: RuntimeEntry,
gen: AsyncGenerator<OmniMessage>,
titleSource?: { userExcerpt: string },
): Promise<void> {
const ctx: UsageContext = {
projectId: entry.projectId,
agentId: entry.agentId,
sessionId: entry.sessionId,
provider: entry.provider,
modelId: entry.modelId,
};
// LLM request failures and tool execution failures aren't expressed via throw (core
// converges them into the message stream), so the try/catch below can't catch them:
// the watcher inspects messages one by one and fishes them out for persistence
// (subagent failures flow through this same stream too; see stream-error-watcher).
const watcher = this.deps.errors
? new StreamErrorWatcher(this.deps.errors, {
projectId: entry.projectId,
agentId: entry.agentId,
sessionId: entry.sessionId,
})
: null;
// Subagent (origin) registration: as soon as session_meta arrives, the child Session
// is persisted so it appears immediately in the sidebar (the frontend picks it up
// when it refreshes the list at task completion). The title material is "the prompt
// of the run_subagent call that spawned this subagent" — the subagent's user input
// is never replayed onto the parent stream (ContextEngine writes the Trace but never
// yields it), so we can't rely on the subagent's first user message; instead we use
// the run_subagent tool_call arguments immediately preceding it on the parent stream
// (depth limited to 1, spawned in order, so taking the most recent one suffices).
/** Subagents registered during this run (keyed by session id); titles are generated for each on completion. */
const children = new Map<string, ChildSession>();
// Unclaimed run_subagent prompts, queued in call order: a single round may spawn
// multiple subagents in parallel, and a subagent's session_meta only carries the
// session id (no tool_call_id), so pairing can only be approximated via FIFO (when
// spawned in parallel and session_meta arrives out of order, two subagents' titles
// may end up swapped — this only affects the displayed title). A call that will
// never produce a subagent must be dequeued, or its prompt would be mismatched onto
// the next subagent: this covers denied calls (approval_decision ≠ allow), and calls
// that were approved but failed before spawning the subagent (e.g. agent_id doesn't
// exist) — the latter is cleaned up when the parent-level tool_call_output settles;
// if the call is still in the queue at that point, it never produced a session_meta.
const subagentPrompts = new Map<string, string>();
try {
for await (const msg of gen) {
// A parent-level (no origin) run_subagent call: record its prompt for the child
// session_meta that arrives later to use as its title.
if (!msg.origin || msg.origin.length === 0) {
const call = runSubagentCall(msg);
if (call) subagentPrompts.set(call.toolCallId, call.prompt);
const denied = deniedToolCallId(msg);
if (denied) subagentPrompts.delete(denied);
const settled = settledToolCallId(msg);
if (settled) subagentPrompts.delete(settled);
} else if (isSessionMeta(msg)) {
// Subagent registration is only a "side effect" — it must never interrupt the
// main run flow on error: wrap the whole thing in a defensive try/catch.
try {
const child = this.registerChildSession(entry, msg, children);
// Only a **direct** subagent (origin length 1) claims a queued parent-level
// run_subagent prompt; deeper sessions are spawned by their own parent and
// shouldn't consume from this queue.
if (child && msg.origin!.length === 1) {
const [pendingId] = subagentPrompts.keys();
if (pendingId !== undefined) {
child.prompt = subagentPrompts.get(pendingId) ?? "";
subagentPrompts.delete(pendingId); // Consumed by this session_meta
}
}
} catch (err) {
this.log(
`[subagent] 子会话登记失败: ${err instanceof Error ? err.message : String(err)}`,
);
this.deps.errors?.record({
source: "subagent",
err,
ctx,
code: "subagent_register_failed",
});
}
} else {
// A subagent's model text: its title is generated from the subagent's **own
// conversation**, so the material is accumulated here.
const nested = nestedAssistantText(msg);
const child = nested ? children.get(nested.sessionId) : undefined;
if (nested && child && child.assistantExcerpt.length < TITLE_EXCERPT_LIMIT) {
child.assistantExcerpt += (child.assistantExcerpt ? "\n" : "") + nested.text;
}
}
// Re-fetch the channel before every publish (matches publishEvent): the channel
// may have been recycled and recreated during a long wait on approval, and
// holding a stale reference would send output to an orphaned, detached channel.
this.deps.channels.get(entry.sessionId).publish(msg);
watcher?.observe(msg);
try {
await this.deps.recorder.record(ctx, msg);
} catch (err) {
this.log(`[usage] 落库失败: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({ source: "usage", err, ctx, code: "usage_insert_failed" });
}
}
} catch (err) {
// 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)}`,
);
this.deps.errors?.record({ source: "session", err, ctx, code: "session_run_failed" });
} finally {
// Wrap-up: persist any still-pending LLM failure and clear the tool-name cache (the watcher's state doesn't carry across runs).
watcher?.close();
entry.approvals.denyAll();
entry.status = "idle";
entry.abort = null;
entry.running = null;
entry.lastActivityMs = Date.now();
this.publishState(entry, "idle");
if (titleSource && titleSource.userExcerpt.trim()) {
// Attempt generation whenever there's user material; whether generation is
// actually needed (title still NULL, etc.) is decided by the generator itself.
// Material is collected by the core Session during run; here we only pass the
// fallback text.
this.deps.titles?.maybeGenerate(ctx, entry.session, {
fallbackText: titleSource.userExcerpt,
});
}
// A subagent's title is likewise generated by the model, with material being the
// subagent's **own conversation**: the prompt that spawned it plus its own reply
// (material the parent Session collects belongs to the parent, hence the explicit
// override here). It piggybacks a one-shot request on the parent Session's bare
// LLM (the child Session object never leaves the SDK); on failure/empty result the
// generator falls back to the prompt's first line.
for (const child of children.values()) {
if (!child.prompt.trim()) continue;
this.deps.titles?.maybeGenerate(
// Bookkeeping: Session/Agent record the subagent (the title belongs to it),
// but the model reference still uses ctx's **parent-Session** pair
// (provider, modelId) — this one-shot request really does run on the parent
// Session's bare LLM (a subagent may switch models via run_subagent's
// model_id).
{ ...ctx, agentId: child.agentId, sessionId: child.sessionId },
entry.session,
{
fallbackText: child.prompt,
material: { userText: child.prompt, assistantText: child.assistantExcerpt },
notifyOn: entry.sessionId, // Notify the frontend via the parent Session's SSE channel
},
);
}
}
}
/**
* Register a subagent: persisted only when the origin message is session_meta
* (agentId is derived from the agent_state path: `<…>/<agentId>/agent_state`).
* **The title is left blank** — it's generated at the end of this run by the model
* from the subagent's own conversation (see drive's finally), falling back to the
* first line of the run_subagent prompt if generation fails. Idempotent (children
* dedup + insertOrIgnore); a subagent has its own Trace, so it's visible in both the
* list and the trace view. On successful registration, the entry is put into
* `children` and returned; a duplicate session_meta returns null.
*/
private registerChildSession(
entry: RuntimeEntry,
msg: OmniMessage,
children: Map<string, ChildSession>,
): ChildSession | null {
if (!isSessionMeta(msg)) return null;
const childSid = msg.origin![msg.origin!.length - 1]!;
if (children.has(childSid)) return null;
const p = msg.payload as SessionMetaPayload;
const agentId = path.basename(path.dirname(p.agent_state));
if (!agentId || agentId === "." || agentId === "..") return null;
this.deps.sessions.insertOrIgnore({
sessionId: childSid,
projectId: entry.projectId,
agentId,
provider: p.provider,
modelId: p.model_id,
workspace: p.workspace,
// A subagent's approvals are inherited from the parent Session; the index row is
// inserted with defaults (matches the convention for Sessions discovered by the CLI).
approvalMode: "allow-all",
title: null,
source: "subagent",
createdAt: new Date().toISOString(),
});
// Make the subagent appear immediately in the sidebar: notify via the parent
// Session's channel (a frontend currently watching the parent run refreshes its list in place).
this.publishEvent(entry, {
type: "session_created",
projectId: entry.projectId,
agentId,
sessionId: childSid,
source: "subagent",
});
const child: ChildSession = {
sessionId: childSid,
agentId,
modelId: p.model_id,
prompt: "",
assistantExcerpt: "",
};
children.set(childSid, child);
return child;
}
private publishState(entry: RuntimeEntry, state: SessionStatus): void {
this.publishEvent(entry, { type: "task_state", state });
}
private publishEvent(entry: RuntimeEntry, event: ServerEvent): void {
this.deps.channels.get(entry.sessionId).publish(event, "server_event");
}
/** Serialize (mutually exclude) execution by sessionId; cleans up the lock-table entry once its chain drains (avoids unbounded growth). */
private async withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
// What's stored in the chain is the already-caught version (used only for
// sequencing, never propagates errors); the caller gets the original result from `next`.
const prev = this.locks.get(sessionId) ?? Promise.resolve();
const next = prev.then(fn);
const settled: Promise<void> = next
.then(
() => undefined,
() => undefined,
)
.then(() => {
// Only delete if still the tail of the chain (no later waiter): preserves mutual-exclusion semantics.
if (this.locks.get(sessionId) === settled) this.locks.delete(sessionId);
});
this.locks.set(sessionId, settled);
return next;
}
}
@@ -0,0 +1,274 @@
/**
* Error capture within the message stream: LLM request
* failures and tool execution failures are **both never expressed via throw** — core
* converges them into the message stream (LLM and Environment handle errors
* internally and never throw), so a try/catch can't catch a single one. This watcher
* hooks onto SessionManager's drive, inspects messages one by one, and fishes them out
* into error_records (source = `llm` / `environment`), matching usage-recorder's shape:
* recognizes only a few payload types, no-op on the rest. **One instance per run/compact**
* (its state wraps up accordingly, see close).
*
* LLM (source = `llm`): reads the status of `request_end` —
* - `failed` → unexpected (not retryable: auth failure, invalid params, etc., needs a human);
* - `timeout` / `malformed` → expected (the engine already reconnects and retries, part
* of normal operation);
* - `aborted` / `completed` are not recorded (the former is a user-initiated interrupt,
* not an error).
*
* The message uses the real reason: `request_end` only carries status, and **the only
* place core carries the actual failure-reason text is the `abort` event's reason**
* (e.g. `llm request error: 401 …` / `malformed response failed after N retries`). So a
* `request_end` failure is first held pending, not persisted immediately, and is
* resolved at the next request boundary:
* - Immediately followed by `abort` → use its reason as the message (the real reason);
* - Immediately followed by `request_begin` (the engine is retrying) → no reason text
* left to wait for, use the status text;
* - Still unresolved when the run ends → close persists it as a fallback.
* Exception: when reason is a user-interrupt message (`aborted …`), it's not trusted —
* "the user clicked stop during backoff" isn't the reason for this timeout, so the
* status text is used instead (that timeout is a genuine failure and is still recorded).
* Pending state is bucketed by origin: subagent messages interleave with the parent
* session's (even more so with parallel subagents), and mixing them up would misattribute.
*
* Environment (source = `environment`): reads `tool_call_output`'s stop_reason ∈
* {failed, timeout} → expected (the error is fed back to the model, and the Agent
* adjusts on its own; `aborted` is denial/interruption, not recorded).
* `tool_call_output` only has tool_call_id, no tool name, so `tool_call_id → tool name`
* is cached (tool_call always arrives before its output), and the tool name is written
* into code (`tool_failed:exec_command`) — so the stats dashboard's "most common error
* code" and the error table can show at a glance which tool failed.
*
* **Attribution (ctx) is recorded against the session that actually produced the error,
* not always the parent Session**: a subagent's LLM failures and tool failures also flow
* through this same stream (carrying origin); if we simply reused the parent ctx passed
* in at construction, filtering errors by Agent would always show 0 for the child Agent
* and an inflated count for the parent — both attribution stats and the troubleshooting
* target would be wrong. So we recognize `session_meta` carrying origin (a subagent's
* first message, always arriving before any of its failures), registering
* `origin → {agentId, sessionId}` (agentId derived from the agent_state path, matching
* SessionManager.registerChildSession's convention); at persist time we look up the
* message's origin: a hit records the subagent, a miss (a main-session message, or
* session_meta hasn't arrived yet) falls back to the parent ctx. projectId is always
* taken from the parent — a subagent is necessarily in the same Project.
*/
import { isEventMessage, isModelMessage, isSessionMeta } from "@prismshadow/penguin-core";
import path from "node:path";
import type { OmniMessage, SessionMetaMessage, StopReason } from "@prismshadow/penguin-core";
import { MESSAGE_MAX } from "./error-recorder.js";
import type { ErrorContext, ErrorKind, ErrorSink } from "./error-recorder.js";
/** Cap on the tool-name cache (bounded, to prevent unbounded growth over a long run; over the limit, evicts the oldest by registration order). */
export const TOOL_NAMES_MAX = 1000;
/**
* Cap on the subagent-identity cache (bounded, same reasoning as TOOL_NAMES_MAX: prevent
* unbounded growth over a long run). The number of in-flight subagents is naturally
* bounded by the subagent concurrency limit and falls far short of this value; over the
* limit, evicts the oldest by registration order (those subagents have long since
* settled, so even if a failure still arrives, it just falls back to the parent ctx —
* i.e., the pre-fix behavior).
*/
export const ORIGIN_CTX_MAX = 200;
/** Recorded LLM failure states (`aborted` / `completed` are not errors and aren't included here). */
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 请求超时(引擎重连重试)。" },
malformed: {
code: "llm_malformed",
kind: "expected",
text: "LLM 响应无法解析(引擎重连重试)。",
},
};
/** Recorded tool failure states (`aborted` = denial/interruption, not an error). */
type ToolFailure = "failed" | "timeout";
function isLlmFailure(s: unknown): s is LlmFailure {
return s === "failed" || s === "timeout" || s === "malformed";
}
function isToolFailure(s: unknown): s is ToolFailure {
return s === "failed" || s === "timeout";
}
/** A user-interrupt abort message (core's `aborted by user` / `aborted during …`): not a failure reason. */
function isUserAbortReason(reason: string): boolean {
return /^aborted\b/i.test(reason);
}
/** The session a message belongs to (last origin element; empty string for the main session) — both pending state and the tool-name cache are bucketed by it. */
function originKey(msg: OmniMessage): string {
const origin = msg.origin;
return origin && origin.length > 0 ? origin[origin.length - 1]! : "";
}
/**
* Take the **tail** of the tool output (not the head) as the message: core appends the
* failure reason (`[tool error] …` / `[tool timeout: …]` / exit code) at the end of the
* output, so truncating from the head would leave only a chunk of normal stdout and
* drop the reason.
*/
function toolFailureText(output: string): string {
if (!output) return "工具执行失败(无输出)。";
if (output.length <= MESSAGE_MAX) return output;
return `…${output.slice(output.length - (MESSAGE_MAX - 1))}`;
}
export class StreamErrorWatcher {
/** LLM failures awaiting a real reason: origin → failure state (see file header; each session has at most one in-flight Request). */
private readonly pending = new Map<string, LlmFailure>();
/** Names of in-flight tool calls: `origin \0 tool_call_id` → tool name (dequeued once output arrives). */
private readonly toolNames = new Map<string, string>();
/** Subagent identity: origin → that subagent's `{agentId, sessionId}` (see the file header's attribution section). */
private readonly originCtx = new Map<string, { agentId: string; sessionId: string }>();
constructor(
private readonly errors: ErrorSink,
private readonly ctx: ErrorContext,
) {}
/** Consume one outgoing message; messages irrelevant to this watcher are a no-op. */
observe(msg: OmniMessage): void {
if (isSessionMeta(msg)) {
this.registerOrigin(msg);
return;
}
if (isModelMessage(msg)) {
this.observeTool(msg);
return;
}
if (isEventMessage(msg)) this.observeLlm(msg);
}
/** run/compact wrap-up: persist any still-pending failure (that never got its abort), and clear caches (prevents leaks). */
close(): void {
for (const key of [...this.pending.keys()]) this.flush(key);
this.pending.clear();
this.toolNames.clear();
this.originCtx.clear();
}
// —— Attribution (see file header) ——
/**
* Register a subagent's identity: `session_meta` carrying origin is the subagent's
* first message, always arriving before any of its failures. agentId is derived from
* the absolute agent_state path (`<…>/<agentId>/agent_state`) — matching
* SessionManager.registerChildSession's convention; not registered if the path is
* malformed (that subagent's failures fall back to the parent ctx — better to
* misattribute than write into a nonexistent agentId). The main session's session_meta
* (no origin) is already the parent ctx and isn't registered.
*/
private registerOrigin(msg: SessionMetaMessage): void {
const key = originKey(msg);
if (!key) return; // Main session
const agentId = path.basename(path.dirname(msg.payload.agent_state));
if (!agentId || agentId === "." || agentId === "..") return;
// Bounded (re-registering refreshes registration order; over the limit, evicts the oldest).
this.originCtx.delete(key);
this.originCtx.set(key, { agentId, sessionId: key });
if (this.originCtx.size > ORIGIN_CTX_MAX) {
const oldest = this.originCtx.keys().next().value;
if (oldest !== undefined) this.originCtx.delete(oldest);
}
}
/**
* The attribution to persist for this origin: a registered subagent hit → record its
* own Agent/Session; a miss (a main-session message, or session_meta hasn't arrived
* yet) → fall back to the parent ctx passed at construction. projectId is always taken
* from the parent (a subagent is necessarily in the same Project).
*/
private ctxFor(key: string): ErrorContext {
const child = this.originCtx.get(key);
if (!child) return this.ctx;
return { projectId: this.ctx.projectId, agentId: child.agentId, sessionId: child.sessionId };
}
// —— LLM ——
private observeLlm(msg: OmniMessage): void {
const p = msg.payload as { type?: string; status?: StopReason; reason?: string | null };
const key = originKey(msg);
if (p.type === "request_end") {
this.flush(key); // Defensive: if a previous failure is still pending (normally resolved by request_begin), persist it first
if (isLlmFailure(p.status)) this.pending.set(key, p.status);
return;
}
// A new attempt begins (the engine is retrying): no reason text left to wait for the previous failure, persist using the status text.
if (p.type === "request_begin") {
this.flush(key);
return;
}
// Interrupted/failed exit: reason is core's only failure-reason text.
if (p.type === "abort") {
this.flush(key, typeof p.reason === "string" ? p.reason : null);
}
}
/**
* Persist a pending LLM failure (no-op if none is pending); `reason` is the abort
* message that arrived afterward. Pending state is already bucketed by origin, so
* `key` is exactly "the session that produced this failure" — attribution is looked
* up from it (see file header).
*/
private flush(key: string, reason?: string | null): void {
const status = this.pending.get(key);
if (status === undefined) return;
this.pending.delete(key);
const spec = LLM_FAILURES[status];
const trimmed = reason?.trim();
// A user-interrupt message isn't a failure reason (see file header); fall back to the status text.
const message = trimmed && !isUserAbortReason(trimmed) ? trimmed : spec.text;
this.errors.record({
source: "llm",
err: message,
ctx: this.ctxFor(key),
code: spec.code,
kind: spec.kind,
});
}
// —— Environment (tool execution) ——
private observeTool(msg: OmniMessage): void {
const p = msg.payload as {
type?: string;
name?: string;
output?: string;
tool_call_id?: string;
stop_reason?: StopReason;
};
if (typeof p.tool_call_id !== "string") return;
const origin = originKey(msg); // The session that made this call (both attribution and the tool-name cache are bucketed by it)
const key = `${origin}\0${p.tool_call_id}`;
if (p.type === "tool_call" && typeof p.name === "string") {
// tool_call arrives before its output: record the tool name (bounded, re-registering refreshes registration order).
this.toolNames.delete(key);
this.toolNames.set(key, p.name);
if (this.toolNames.size > TOOL_NAMES_MAX) {
const oldest = this.toolNames.keys().next().value;
if (oldest !== undefined) this.toolNames.delete(oldest);
}
return;
}
if (p.type !== "tool_call_output") return;
const name = this.toolNames.get(key);
this.toolNames.delete(key); // This call has settled: dequeue it, the cache only keeps in-flight calls
if (!isToolFailure(p.stop_reason)) return; // completed / aborted (denial, user interrupt) are not errors
this.errors.record({
source: "environment",
err: toolFailureText(p.output ?? ""),
ctx: this.ctxFor(origin),
// The tool name goes into code: so the stats dashboard's "most common error code" and table can show which tool failed.
code: `tool_${p.stop_reason}:${name ?? "unknown"}`,
kind: "expected",
});
}
}
@@ -0,0 +1,137 @@
/**
* The **policy layer** for automatic Session title generation (conversation-page
* extension).
*
* "How to generate" lives in the core SDK (`session.generateTitle`: an out-of-band
* one-shot request on the session's own Model, no tools, thinking disabled, writes no
* history/Trace); this module is only responsible for host-side policy:
* - When to generate: after a Task completes and the DB row's title is still NULL
* (i.e. after the first successful conversation);
* - Persistence and notification: writes sessions.title and pushes a `session_title`
* server event to the Session channel;
* - Bookkeeping: the one-shot request's token consumption is converted to token_usage
* and handed to usage-recorder for persistence;
* - Silent failure (logged): the title stays NULL and naturally retries after the next
* Task completes.
*/
import { emptyTokenCounts, sanitizeTitle, tokenUsage } from "@prismshadow/penguin-core";
import type { SessionsRepo } from "../db/repos/sessions.js";
import type { ChannelHub } from "./channel.js";
import type { ErrorSink } from "./error-recorder.js";
import type { RuntimeSession } from "./session-manager.js";
import type { UsageContext, UsageRecorder } from "./usage-recorder.js";
export interface TitleGeneratorDeps {
sessions: SessionsRepo;
channels: ChannelHub;
recorder: Pick<UsageRecorder, "record">;
/** Error persistence (optional: without it, only logs — same as before this was wired up). */
errors?: ErrorSink;
log?: (line: string) => void;
}
/** Host-side parameters for one title-generation request. */
export interface TitleRequest {
/** Fallback material for when the LLM fails or returns an empty result (cleaned and truncated from the first non-empty line). */
fallbackText: string;
/** Material override (for subagents — the material is the subagent's own conversation); defaults to the first Task's material self-collected by the core Session. */
material?: { userText: string; assistantText: string };
/** The channel to push the `session_title` event to; defaults to `ctx.sessionId`. A
* subagent has no SSE channel of its own, so its title must reach the frontend via
* the **parent Session's** channel (the list updates in place by sessionId). */
notifyOn?: string;
}
/** session-manager's minimal dependency on the title generator (tests inject a fake implementation). */
export interface TitleNotifier {
maybeGenerate(
ctx: UsageContext,
session: Pick<RuntimeSession, "generateTitle">,
req: TitleRequest,
): void;
}
export class TitleGenerator implements TitleNotifier {
private readonly inflight = new Set<string>();
private readonly log: (line: string) => void;
constructor(private readonly deps: TitleGeneratorDeps) {
this.log = deps.log ?? ((line) => console.error(line));
}
/** Generate a title in the background (fire-and-forget) when conditions are met: the row exists, title is still NULL, and no generation is already in flight. */
maybeGenerate(
ctx: UsageContext,
session: Pick<RuntimeSession, "generateTitle">,
req: TitleRequest,
): void {
const row = this.deps.sessions.findById(ctx.sessionId);
if (!row || row.title !== null) return;
if (this.inflight.has(ctx.sessionId)) return;
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.deps.errors?.record({ source: "title", err, ctx, code: "title_failed" });
})
.finally(() => {
this.inflight.delete(ctx.sessionId);
});
}
private async generate(
ctx: UsageContext,
session: Pick<RuntimeSession, "generateTitle">,
req: TitleRequest,
): Promise<void> {
let title: string | null = null;
try {
// Material defaults to what the core Session self-collects during run; it's only
// overridden in scenarios like subagents where the material isn't on that Session.
const res = await session.generateTitle(
req.material ? { material: req.material } : undefined,
);
title = res.title;
// The one-shot request's real consumption is metered as usual (converted to token_usage and handed to recorder, attributed to this Session).
if (res.usage) {
try {
await this.deps.recorder.record(ctx, tokenUsage(emptyTokenCounts(), res.usage));
} catch (err) {
this.log(`[title] 用量落库失败: ${err instanceof Error ? err.message : String(err)}`);
this.deps.errors?.record({
source: "title",
err,
ctx,
code: "title_usage_insert_failed",
});
}
}
} 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.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.
const finalTitle = title ?? fallbackTitle(req.fallbackText);
if (finalTitle === null) return;
// There may already be a concurrent write during generation (e.g. a future manual rename): only persist if still NULL.
const latest = this.deps.sessions.findById(ctx.sessionId);
if (!latest || latest.title !== null) return;
this.deps.sessions.updateTitle(ctx.sessionId, finalTitle);
this.deps.channels
.get(req.notifyOn ?? ctx.sessionId)
.publish(
{ type: "session_title", sessionId: ctx.sessionId, title: finalTitle },
"server_event",
);
}
}
/** Fallback title: take the material's first non-empty line, sanitize and truncate; if sanitizing empties it out (pure punctuation, etc.) fall back to the truncated original text; returns null if all-whitespace. */
function fallbackTitle(text: string): string | null {
const firstLine = text.split("\n").find((l) => l.trim().length > 0);
if (!firstLine) return null;
// sanitizeTitle strips a pure-punctuation line down to empty — in that case keep the truncated original text, guaranteeing "a title is always obtained".
return sanitizeTitle(firstLine) ?? firstLine.trim().slice(0, 30);
}
@@ -0,0 +1,116 @@
/**
* Usage persistence.
*
* Consumes the Session output stream:
* - A subagent's `session_meta` (carrying origin) → registers the mapping "origin's
* last session_id → (provider, model_id)" (a subagent may use a different Model, so
* cost is priced against the actual Model used);
* - `token_usage` → inserts one usage_records row: the four token fields come from
* `payload.request` (per-Request increment; subagents are recorded one row at a
* time, with `session_id` attributed to their owning main Session). Only tokens are
* persisted, not cost — pricing may be added later, and cost is computed on the fly
* against current pricing when usage-service queries.
* The attribution key is always the paired reference `(provider, model_id)` (the same
* model_id name across different vendors is attributed separately).
*/
import { isEventMessage, isSessionMeta } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import { formatLocalDate } from "../internal/dates.js";
import type { UsageRepo } from "../db/repos/usage.js";
/** Attribution context for one record (top-level Session scope). */
export interface UsageContext {
projectId: string;
agentId: string;
/** Top-level Session id (the current actual id after self-heal). */
sessionId: string;
/** Vendor grouping for the top-level Session's model (paired with modelId; the fallback attribution when the origin mapping has no hit). */
provider: string;
/** Upstream model_id of the top-level Session (paired with provider). */
modelId: string;
}
/** Cap on the subagent attribution mapping: over the limit, evicts the oldest by insertion order (an evicted entry falls back to the main Session's Model attribution). */
export const ORIGIN_MODELS_MAX = 1000;
export class UsageRecorder {
/** Subagent model attribution mapping: origin's last session_id → paired reference (session_id is globally unique). */
private readonly originModels = new Map<string, { provider: string; modelId: string }>();
constructor(
private readonly usage: UsageRepo,
private readonly now: () => Date = () => new Date(),
) {}
/** Consume one outgoing message; messages other than session_meta / token_usage are a no-op. */
async record(ctx: UsageContext, msg: OmniMessage): Promise<void> {
if (isSessionMeta(msg) && msg.origin && msg.origin.length > 0) {
const originSessionId = msg.origin[msg.origin.length - 1]!;
// Bounded mapping (avoids unbounded growth over a long-running process): re-inserting refreshes insertion order, over the limit evicts the oldest.
this.originModels.delete(originSessionId);
this.originModels.set(originSessionId, {
provider: msg.payload.provider,
modelId: msg.payload.model_id,
});
if (this.originModels.size > ORIGIN_MODELS_MAX) {
const oldest = this.originModels.keys().next().value;
if (oldest !== undefined) this.originModels.delete(oldest);
}
return;
}
if (!isEventMessage(msg)) return;
const payload = msg.payload as {
type?: string;
request?: { cache_read: number; cache_write: number; output: number; total: number };
status?: string;
};
const originSessionId =
msg.origin && msg.origin.length > 0 ? msg.origin[msg.origin.length - 1]! : null;
// Empty origin → main session's Model; otherwise look up the mapping, falling back to the main Session's Model (paired) on a miss.
const ref =
originSessionId === null
? { provider: ctx.provider, modelId: ctx.modelId }
: (this.originModels.get(originSessionId) ?? {
provider: ctx.provider,
modelId: ctx.modelId,
});
const now = this.now();
const base = {
ts: now.toISOString(),
date: formatLocalDate(now),
projectId: ctx.projectId,
agentId: ctx.agentId,
sessionId: ctx.sessionId,
originSessionId,
provider: ref.provider,
modelId: ref.modelId,
};
if (payload.type === "token_usage" && payload.request) {
// A successful request: persist along with tokens (status defaults to completed).
const r = payload.request;
this.usage.insert({
...base,
cacheRead: r.cache_read,
cacheWrite: r.cache_write,
output: r.output,
total: r.total,
});
return;
}
// A failed request (request_end and not completed, usually with no token_usage):
// persist 0 tokens + status, feeding the "model success rate" stat; a successful
// request is already counted once via the token_usage branch above, not repeated here.
if (payload.type === "request_end" && payload.status && payload.status !== "completed") {
this.usage.insert({
...base,
cacheRead: 0,
cacheWrite: 0,
output: 0,
total: 0,
status: payload.status,
});
}
}
}
@@ -0,0 +1,96 @@
/**
* Admin user backend: user list / create / reset password / delete.
*
* - Create: username is the user_id (^[a-z][a-z0-9_-]{1,31}$); admin sets the initial
* password, flagged with password_is_initial; a default Project `proj-<username>` is
* auto-created, rolling back the user row on failure.
* - Reset password: also flags the password as initial and clears all of the user's
* login sessions (forcing re-login).
* - Delete: the built-in admin cannot be deleted; Projects owned by the user are
* deleted along with it (including data directories), with sessions/memberships/UI
* preferences cascade-deleted via foreign keys.
*/
import type { UserInfo } from "../api/types.js";
import { HttpError } from "../http/errors.js";
import { MIN_PASSWORD_LENGTH, toUserInfo } from "../auth/service.js";
import { hashPassword } from "../auth/password.js";
import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js";
import type { ProjectsRepo } from "../db/repos/projects.js";
import type { UserRow, UsersRepo } from "../db/repos/users.js";
import { SEMANTIC_ID_RULE, USERNAME_PATTERN } from "./ids.js";
import type { ProjectService } from "./project-service.js";
export interface AdminServiceDeps {
users: UsersRepo;
authSessions: AuthSessionsRepo;
projects: ProjectsRepo;
projectService: ProjectService;
now?: () => Date;
}
export class AdminService {
private readonly now: () => Date;
constructor(private readonly deps: AdminServiceDeps) {
this.now = deps.now ?? (() => new Date());
}
listUsers(): UserInfo[] {
return this.deps.users.list().map(toUserInfo);
}
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}。`);
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
}
if (this.deps.users.findById(userId)) {
throw new HttpError(409, "user_exists", `用户已存在:${userId}。`);
}
const user: UserRow = {
userId,
passwordHash: await hashPassword(password),
isAdmin: false,
passwordIsInitial: true,
createdAt: this.now().toISOString(),
};
this.deps.users.insert(user);
try {
await this.deps.projectService.provisionInitialProject(user, false);
} catch (err) {
// Compensation: roll back the user row if default Project creation fails (e.g. proj-<username> already taken).
this.deps.users.delete(user.userId);
throw err;
}
return toUserInfo(user);
}
/** 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}。`);
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
}
this.deps.users.updatePassword(userId, await hashPassword(password), true);
this.deps.authSessions.deleteByUser(userId);
}
/** Delete user: the built-in admin cannot be deleted; owned Projects (including data directories) are deleted along with it. */
async deleteUser(userId: string): Promise<void> {
const target = this.deps.users.findById(userId);
if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${userId}。`);
}
if (target.isAdmin) {
throw new HttpError(409, "cannot_delete_admin", "内置管理员不能删除。");
}
for (const project of this.deps.projects.listByOwner(userId)) {
await this.deps.projectService.destroyProject(project.projectId);
}
this.deps.users.delete(userId); // auth_sessions / project_members / ui_prefs cascade-deleted
}
}
@@ -0,0 +1,341 @@
/**
* Agent config read/write (config is an editable file).
*
* system_config.yaml is edited via yaml's `parseDocument`: only the keys provided in
* the request are updated, the rest of the file (including comments) is preserved
* as-is; AGENTS.md is overwritten in full.
* The vault (agent_state/.vault.toml) is read/written via core's loadAgentVault/saveAgentVault;
* plaintext values only ever hit disk, and are always masked in responses.
*/
import fs from "node:fs/promises";
import { parseDocument, parse as parseYaml } from "yaml";
import {
agentsMdPath,
agentStateDir,
agentStateVersion,
VAULT_VALUE_MAX_LENGTH,
isValidVaultKey,
loadAgentVault,
saveAgentVault,
systemConfigPath,
} from "@prismshadow/penguin-core";
import type {
MCPServerConfig,
ThinkingLevelName,
ToolDefinitionConfig,
} from "@prismshadow/penguin-core";
import type {
AgentConfigDto,
AgentConfigUpdateRequest,
AgentModelConfigDto,
AgentCompactionConfigDto,
VaultEntryInfo,
VaultResponse,
VaultUpdateRequest,
} from "../api/types.js";
import { HttpError } from "../http/errors.js";
import { badRequest, optionalEnum, optionalNumber, optionalString } from "../http/validate.js";
import { maskApiKey } from "./project-config-service.js";
const THINKING_LEVELS: readonly ThinkingLevelName[] = ["none", "low", "medium", "high", "xhigh"];
const COMPACTION_MODES = ["summarize", "discard"] as const;
function asRecord(v: unknown): Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
}
export interface AgentConfigView {
agentsMd: string;
systemConfigYaml: string;
config: AgentConfigDto;
stateDir: string;
}
export class AgentConfigService {
constructor(private readonly root: string) {}
/** Whether the Agent exists (determined by the presence of system_config.yaml, matching the CLI's convention). */
async exists(projectId: string, agentId: string): Promise<boolean> {
try {
await fs.access(systemConfigPath(this.root, projectId, agentId));
return true;
} catch {
return false;
}
}
async requireExists(projectId: string, agentId: string): Promise<void> {
if (!(await this.exists(projectId, agentId))) {
throw new HttpError(404, "agent_not_found", "Agent 不存在。");
}
}
/**
* Read list-card metadata: name / description + tool count (sum of tools.builtin
* and tools.mcpServers entries; MCP counted per server). Silently falls back to
* empty / 0 if the file is corrupt.
*/
async readCardMeta(
projectId: string,
agentId: string,
): Promise<{ name?: string; description?: string; toolCount: number; version: number }> {
try {
const raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
const parsed = asRecord(parseYaml(raw));
const tools = asRecord(parsed.tools);
const countOf = (v: unknown): number => (Array.isArray(v) ? v.length : 0);
return {
...(typeof parsed.name === "string" ? { name: parsed.name } : {}),
...(typeof parsed.description === "string" ? { description: parsed.description } : {}),
toolCount: countOf(tools.builtin) + countOf(tools.mcpServers),
version: agentStateVersion({ version: parsed.version as number | undefined }),
};
} catch {
return { toolCount: 0, version: 1 };
}
}
/** Structured config view (matching the edit form's shape) + raw text + AGENTS.md + State path. */
async getConfig(projectId: string, agentId: string): Promise<AgentConfigView> {
await this.requireExists(projectId, agentId);
const yamlPath = systemConfigPath(this.root, projectId, agentId);
const systemConfigYaml = await fs.readFile(yamlPath, "utf8");
const parsed = asRecord(parseYaml(systemConfigYaml));
const model = asRecord(parsed.model);
const compaction = asRecord(parsed.compaction);
const tools = asRecord(parsed.tools);
let agentsMd = "";
try {
agentsMd = await fs.readFile(agentsMdPath(this.root, projectId, agentId), "utf8");
} catch {
// Treat a missing AGENTS.md as an empty file (it normally exists after initialization).
}
const modelDto: AgentModelConfigDto = {
...(typeof model.max_tokens === "number" ? { maxTokens: model.max_tokens } : {}),
...(typeof model.thinking_level === "string"
? { thinkingLevel: model.thinking_level as ThinkingLevelName }
: {}),
...(typeof model.timeoutMs === "number" ? { timeoutMs: model.timeoutMs } : {}),
};
const compactionDto: AgentCompactionConfigDto = {
...(typeof compaction.max_context_length === "number"
? { maxContextLength: compaction.max_context_length }
: {}),
...(typeof compaction.max_session_turns === "number"
? { maxSessionTurns: compaction.max_session_turns }
: {}),
...(compaction.mode === "summarize" || compaction.mode === "discard"
? { mode: compaction.mode }
: {}),
...(typeof compaction.prompt === "string" ? { prompt: compaction.prompt } : {}),
};
const config: AgentConfigDto = {
...(typeof parsed.name === "string" ? { name: parsed.name } : {}),
...(typeof parsed.description === "string" ? { description: parsed.description } : {}),
version: agentStateVersion({ version: parsed.version as number | undefined }),
systemPrompt: typeof parsed.system_prompt === "string" ? parsed.system_prompt : "",
...(typeof parsed.max_turns === "number" ? { maxTurns: parsed.max_turns } : {}),
...(Object.keys(modelDto).length > 0 ? { model: modelDto } : {}),
...(Object.keys(compactionDto).length > 0 ? { compaction: compactionDto } : {}),
toolsBuiltin: Array.isArray(tools.builtin) ? (tools.builtin as ToolDefinitionConfig[]) : [],
mcpServers: Array.isArray(tools.mcpServers) ? (tools.mcpServers as MCPServerConfig[]) : [],
};
return {
agentsMd,
systemConfigYaml,
config,
stateDir: agentStateDir(this.root, projectId, agentId),
};
}
/**
* PUT accepts any subset: only the provided keys are updated (parseDocument
* preserves comments and untouched content); agentsMd is overwritten in full.
* Numeric validation: >0 or -1; thinkingLevel / mode are validated as enums.
*/
async updateConfig(
projectId: string,
agentId: string,
req: AgentConfigUpdateRequest,
): Promise<void> {
await this.requireExists(projectId, agentId);
// Finish all config validation and document changes before writing to disk
// (if validation fails, AGENTS.md is not written either, avoiding a partial update).
if (req.config !== undefined) {
await this.applyConfigUpdate(projectId, agentId, req.config);
}
if (req.agentsMd !== undefined) {
await fs.writeFile(agentsMdPath(this.root, projectId, agentId), req.agentsMd, "utf8");
}
}
private async applyConfigUpdate(
projectId: string,
agentId: string,
config: NonNullable<AgentConfigUpdateRequest["config"]>,
): Promise<void> {
const cfg = config as unknown as Record<string, unknown>;
const yamlPath = systemConfigPath(this.root, projectId, agentId);
const doc = parseDocument(await fs.readFile(yamlPath, "utf8"));
const setIfProvided = (path: string[], value: unknown): void => {
if (value !== undefined) doc.setIn(path, value);
};
setIfProvided(["name"], optionalString(cfg, "name", { maxLen: 100, label: "name" }));
setIfProvided(
["description"],
optionalString(cfg, "description", { maxLen: 2000, label: "description" }),
);
setIfProvided(
["system_prompt"],
optionalString(cfg, "systemPrompt", { label: "systemPrompt" }),
);
setIfProvided(
["max_turns"],
optionalNumber(cfg, "maxTurns", { integer: true, positiveOrMinusOne: true }),
);
if (cfg.model !== undefined) {
const model = asRecord(cfg.model);
setIfProvided(
["model", "max_tokens"],
optionalNumber(model, "maxTokens", { integer: true, positiveOrMinusOne: true }),
);
setIfProvided(
["model", "thinking_level"],
optionalEnum(model, "thinkingLevel", THINKING_LEVELS),
);
setIfProvided(
["model", "timeoutMs"],
optionalNumber(model, "timeoutMs", { integer: true, positiveOrMinusOne: true }),
);
}
if (cfg.compaction !== undefined) {
const compaction = asRecord(cfg.compaction);
setIfProvided(
["compaction", "max_context_length"],
optionalNumber(compaction, "maxContextLength", { integer: true, positiveOrMinusOne: true }),
);
setIfProvided(
["compaction", "max_session_turns"],
optionalNumber(compaction, "maxSessionTurns", { integer: true, positiveOrMinusOne: true }),
);
setIfProvided(["compaction", "mode"], optionalEnum(compaction, "mode", COMPACTION_MODES));
setIfProvided(["compaction", "prompt"], optionalString(compaction, "prompt"));
}
if (cfg.toolsBuiltin !== undefined) {
doc.setIn(["tools", "builtin"], validateToolsBuiltin(cfg.toolsBuiltin));
}
if (cfg.mcpServers !== undefined) {
doc.setIn(["tools", "mcpServers"], validateMcpServers(cfg.mcpServers));
}
await fs.writeFile(yamlPath, doc.toString(), "utf8");
}
/** Read the Agent vault (agent_state/.vault.toml): values are always masked, plaintext is never sent to the client. */
async getVault(projectId: string, agentId: string): Promise<VaultResponse> {
await this.requireExists(projectId, agentId);
const vault = await loadAgentVault(this.root, projectId, agentId);
const entries: VaultEntryInfo[] = Object.entries(vault).map(([key, value]) => ({
key,
valueMasked: maskApiKey(value),
}));
return { entries };
}
/**
* PUT replaces the whole vault table (same semantics as models): keys absent from
* the body are deleted; omitting value keeps the existing value (a new key must
* provide a value). Key names are validated against shell environment variable
* naming rules (same rule as core); deleting everything removes the whole
* .vault.toml file.
*/
async updateVault(
projectId: string,
agentId: string,
req: VaultUpdateRequest,
): Promise<VaultResponse> {
await this.requireExists(projectId, agentId);
const prev = await loadAgentVault(this.root, projectId, agentId);
const seen = new Set<string>();
const nextVault: Record<string, string> = {};
for (const entry of req.entries) {
if (!isValidVaultKey(entry.key)) {
throw badRequest(
`vault 键名不合法:${entry.key}(仅字母、数字与下划线,且不能以数字开头)。`,
);
}
if (seen.has(entry.key)) {
throw badRequest(`entries 中存在重复的键名:${entry.key}。`);
}
seen.add(entry.key);
const prevValue = prev[entry.key];
if (entry.value !== undefined) {
// 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} 字符)。`);
}
nextVault[entry.key] = entry.value;
} else if (prevValue !== undefined) {
nextVault[entry.key] = prevValue;
} else {
throw badRequest(`新增键 ${entry.key} 必须提供 value。`);
}
}
await saveAgentVault(this.root, projectId, agentId, nextVault);
return this.getVault(projectId, agentId);
}
}
function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
if (!Array.isArray(value)) throw badRequest("toolsBuiltin 必须是数组。");
return value.map((item, i) => {
const t = asRecord(item);
if (typeof t.name !== "string" || t.name.length === 0) {
throw badRequest(`toolsBuiltin[${i}].name 必须是非空字符串。`);
}
if (typeof t.description !== "string") {
throw badRequest(`toolsBuiltin[${i}].description 必须是字符串。`);
}
if (t.permission !== undefined && t.permission !== "r" && t.permission !== "rw") {
throw badRequest(`toolsBuiltin[${i}].permission 必须是 r / rw 之一。`);
}
if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") {
throw badRequest(`toolsBuiltin[${i}].forModel 必须是 vision / text-only 之一。`);
}
optionalNumber(t, "timeoutMs", {
integer: true,
positiveOrMinusOne: true,
label: `toolsBuiltin[${i}].timeoutMs`,
});
optionalNumber(t, "maxOutputLength", {
integer: true,
positiveOrMinusOne: true,
label: `toolsBuiltin[${i}].maxOutputLength`,
});
return t as unknown as ToolDefinitionConfig;
});
}
function validateMcpServers(value: unknown): MCPServerConfig[] {
if (!Array.isArray(value)) throw badRequest("mcpServers 必须是数组。");
return value.map((item, i) => {
const s = asRecord(item);
if (typeof s.name !== "string" || s.name.length === 0) {
throw badRequest(`mcpServers[${i}].name 必须是非空字符串。`);
}
if (s.config === null || typeof s.config !== "object" || Array.isArray(s.config)) {
throw badRequest(`mcpServers[${i}].config 必须是对象。`);
}
return s as unknown as MCPServerConfig;
});
}
@@ -0,0 +1,219 @@
/**
* Agent service.
*
* The list is the union of "DB index ∪ directory scan": a subdirectory under
* `<project>/` containing `agent_state/system_config.yaml` is treated as an Agent;
* unmanaged ones found are backfilled into the DB — this handles Agents created
* directly via the CLI.
* Create: generate agent-<8hex>, initialize Agent State via core's `createAgent`,
* then write name/description into system_config.yaml (parseDocument preserves the
* template's comments).
*/
import fs from "node:fs/promises";
import { HttpError } from "../http/errors.js";
import {
agentDir,
agentsDir,
agentsMdPath,
BUILTIN_AGENT_IDS,
createAgent as coreCreateAgent,
isValidId,
loadAgentVault,
scheduleDir,
systemConfigPath,
} from "@prismshadow/penguin-core";
import type { AgentsRepo } from "../db/repos/agents.js";
import { SEMANTIC_ID_PATTERN, SEMANTIC_ID_RULE } from "./ids.js";
import type { AgentConfigService } from "./agent-config-service.js";
export interface AgentListItem {
agentId: string;
name?: string;
description?: string;
createdAt?: string;
/** Last config modification time: the later of system_config.yaml / AGENTS.md mtime. */
updatedAt?: string;
/** Tool count: number of tools.builtin + tools.mcpServers entries (MCP counted per server). */
toolCount: number;
/** Agent State version number (missing field treated as 1). */
version: number;
/** Number of vault keys. */
vaultKeyCount: number;
/** Number of scheduled tasks (count of .toml files under schedule/, including invalid ones). */
scheduleCount: number;
}
export class AgentService {
constructor(
private readonly root: string,
private readonly agents: AgentsRepo,
private readonly agentConfig: AgentConfigService,
) {}
/** Union of DB index ∪ directory scan; unmanaged directory Agents are backfilled into the DB. */
async listAgents(projectId: string): Promise<AgentListItem[]> {
const known = new Map(this.agents.list(projectId).map((r) => [r.agentId, r]));
let entries: string[] = [];
try {
const dirents = await fs.readdir(agentsDir(this.root, projectId), { withFileTypes: true });
entries = dirents.filter((d) => d.isDirectory()).map((d) => d.name);
} catch {
// The Project's agents/ directory doesn't exist yet (no Agent directories): return from the DB index only.
}
for (const agentId of entries) {
if (known.has(agentId) || !isValidId(agentId)) continue;
const configPath = systemConfigPath(this.root, projectId, agentId);
let createdAt: string;
try {
const stat = await fs.stat(configPath);
createdAt = (stat.birthtime.getTime() > 0 ? stat.birthtime : stat.mtime).toISOString();
} catch {
continue; // A directory without system_config.yaml is not an Agent (e.g. a temp folder)
}
const row = { projectId, agentId, createdAt };
this.agents.insertOrIgnore(row);
known.set(agentId, row);
}
// Meta reads and mtime stats for each Agent run in parallel (Promise.all preserves the sorted order).
const sorted = [...known.values()].sort((a, b) =>
a.createdAt === b.createdAt
? a.agentId.localeCompare(b.agentId)
: a.createdAt < b.createdAt
? -1
: 1,
);
return Promise.all(
sorted.map(async (row) => {
const [meta, updatedAt, vaultKeyCount, scheduleCount] = await Promise.all([
this.agentConfig.readCardMeta(projectId, row.agentId),
this.configUpdatedAt(projectId, row.agentId),
this.vaultKeyCount(projectId, row.agentId),
this.scheduleCount(projectId, row.agentId),
]);
return {
agentId: row.agentId,
...meta,
createdAt: row.createdAt,
...(updatedAt !== undefined ? { updatedAt } : {}),
vaultKeyCount,
scheduleCount,
};
}),
);
}
/** Number of vault keys (falls back to 0 on read failure). */
private async vaultKeyCount(projectId: string, agentId: string): Promise<number> {
try {
return Object.keys(await loadAgentVault(this.root, projectId, agentId)).length;
} catch {
return 0;
}
}
/** Number of scheduled tasks: count of .toml files under schedule/ (0 if the directory doesn't exist). */
private async scheduleCount(projectId: string, agentId: string): Promise<number> {
try {
const names = await fs.readdir(scheduleDir(this.root, projectId, agentId));
return names.filter((n) => n.endsWith(".toml")).length;
} catch {
return 0;
}
}
/** Last config modification time: the later of system_config.yaml and AGENTS.md mtime; omitted if neither is readable. */
private async configUpdatedAt(projectId: string, agentId: string): Promise<string | undefined> {
const paths = [
systemConfigPath(this.root, projectId, agentId),
agentsMdPath(this.root, projectId, agentId),
];
const times = await Promise.all(
paths.map(async (p) => {
try {
return (await fs.stat(p)).mtime.getTime();
} catch {
return 0;
}
}),
);
const max = Math.max(...times);
return max > 0 ? new Date(max).toISOString() : undefined;
}
/**
* Delete an Agent: the sole built-in Agent
* default_agent (shared with the CLI, the default conversation Agent) cannot be
* deleted; callers must first drain any active run via manager.abortAgent.
* The directory is deleted recursively (including Trace), and the DB's
* agents/sessions index rows are removed along with it; usage records are kept
* (historical stats are unaffected).
*/
async deleteAgent(projectId: string, agentId: string): Promise<void> {
if (BUILTIN_AGENT_IDS.includes(agentId)) {
throw new HttpError(
409,
"cannot_delete_builtin_agent",
"内置 Agent(default_agent)随 Project 供给,不能从 Web 删除。",
);
}
await fs.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true });
this.agents.delete(projectId, agentId);
}
/**
* Create an Agent: the id is chosen by the creator (a semantic id, checked for
* duplicates against both the DB and the directory within the Project — a 409
* if taken, which naturally also blocks built-in Agent ids) → initialize State →
* write name/description (name defaults to the id).
*/
async createAgent(
projectId: string,
agentId: string,
name?: string,
description?: string,
): Promise<AgentListItem> {
if (!SEMANTIC_ID_PATTERN.test(agentId)) {
throw new HttpError(400, "invalid_agent_id", `Agent id 须为 2~64 位:${SEMANTIC_ID_RULE}。`);
}
const taken =
this.agents.exists(projectId, agentId) ||
(await fs.stat(agentDir(this.root, projectId, agentId)).then(
() => true,
() => false,
));
if (taken) {
throw new HttpError(409, "agent_exists", `Agent id 已被占用:${agentId}。`);
}
const displayName = name ?? agentId;
await coreCreateAgent({ root: this.root, projectId, agentId });
try {
await this.agentConfig.updateConfig(projectId, agentId, {
config: { name: displayName, ...(description !== undefined ? { description } : {}) },
});
} catch (err) {
// If initialization fails partway through, clean up the directory: an orphaned
// directory would make retries with this agent id 409 forever.
await fs
.rm(agentDir(this.root, projectId, agentId), { recursive: true, force: true })
.catch(() => {});
throw err;
}
const createdAt = new Date().toISOString();
this.agents.insertOrIgnore({ projectId, agentId, createdAt });
// The init template ships with a default toolset and version number; read back the actual values.
const meta = await this.agentConfig.readCardMeta(projectId, agentId);
return {
agentId,
name: displayName,
...(description !== undefined ? { description } : {}),
createdAt,
updatedAt: createdAt,
toolCount: meta.toolCount,
version: meta.version,
vaultKeyCount: 0,
scheduleCount: 0,
};
}
}
@@ -0,0 +1,216 @@
/**
* Benchmark score reading (read-only display): walks `benchmarks/<id>/`, reads
* `benchmark_config.toml` (title,
* description, evaluation Model, per-case run count `runs`) and `scoreboard.yaml`
* (evaluations[], scoreboard v2: each case carries a runs array and a summary).
* Content is created and refined by benchmark_builder; the server only reads it.
* Missing or corrupt files always degrade gracefully (title falls back to the
* directory name, scores come back empty) rather than throwing.
*
* The three per-case metrics trust the file's own values; when missing they're
* computed as the average over the runs array. The old format (no runs at the
* case level, a single session_id) is parsed as a single run — the server backfills
* one run entry.
* Docs: /docs/self-improvement § "Benchmark storage".
*/
import fs from "node:fs/promises";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import { parse as parseYaml } from "yaml";
import { benchmarksDir } from "@prismshadow/penguin-core";
import type {
BenchmarkCaseScore,
BenchmarkEvaluation,
BenchmarkRunScore,
BenchmarkSummary,
BenchmarksResponse,
} from "../api/types.js";
function asRecord(v: unknown): Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
}
function numberOr(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function stringOr(v: unknown): string | undefined {
return typeof v === "string" && v !== "" ? v : undefined;
}
/** Shapes a single run entry: score is the minimum requirement, other fields tolerate being absent; a bad entry returns null and is dropped. */
function toRun(v: unknown): BenchmarkRunScore | null {
const r = asRecord(v);
const score = numberOr(r.score);
if (score === undefined) return null;
const cost = numberOr(r.cost);
const durationMs = numberOr(r.duration_ms);
const sessionId = stringOr(r.session_id);
return {
score,
...(cost !== undefined ? { cost } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(sessionId !== undefined ? { sessionId } : {}),
};
}
/** Average of a metric across runs; undefined when there's no value at all (never forced to 0). */
function averageOf(runs: BenchmarkRunScore[], pick: (r: BenchmarkRunScore) => number | undefined) {
const values = runs.map(pick).filter((v): v is number => v !== undefined);
if (values.length === 0) return undefined;
return values.reduce((a, b) => a + b, 0) / values.length;
}
/**
* Shapes a case-level entry (scoreboard v2): the three metrics trust the file's own
* values, falling back to an average over runs when missing; the old format (no
* runs, a single case-level session_id) is backfilled into a single run. case and a
* score (from the file or derivable from runs) are the minimum requirement,
* otherwise the entry is dropped.
*/
function toCase(v: unknown): BenchmarkCaseScore | null {
const cr = asRecord(v);
const caseId = stringOr(cr.case);
if (caseId === undefined) return null;
const parsedRuns = Array.isArray(cr.runs)
? cr.runs.map(toRun).filter((r): r is BenchmarkRunScore => r !== null)
: [];
const score = numberOr(cr.score) ?? averageOf(parsedRuns, (r) => r.score);
if (score === undefined) return null;
const cost = numberOr(cr.cost) ?? averageOf(parsedRuns, (r) => r.cost);
const durationMs = numberOr(cr.duration_ms) ?? averageOf(parsedRuns, (r) => r.durationMs);
const sessionId = stringOr(cr.session_id);
const runs: BenchmarkRunScore[] =
parsedRuns.length > 0
? parsedRuns
: [
// The old format is parsed as a single run: the case-level values are that run's raw result.
{
score,
...(cost !== undefined ? { cost } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(sessionId !== undefined ? { sessionId } : {}),
},
];
return {
case: caseId,
score,
...(cost !== undefined ? { cost } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
...(sessionId !== undefined ? { sessionId } : {}),
runs,
};
}
/** Shapes a single evaluation record: time and score are the minimum requirement, other fields (summary, etc.) tolerate being absent. */
function toEvaluation(v: unknown): BenchmarkEvaluation | null {
const r = asRecord(v);
const time = r.time instanceof Date ? r.time.toISOString() : r.time;
const score = numberOr(r.score);
if (typeof time !== "string" || time === "" || score === undefined) return null;
const cases: BenchmarkCaseScore[] = Array.isArray(r.cases)
? r.cases.map(toCase).filter((c): c is BenchmarkCaseScore => c !== null)
: [];
const summary = stringOr(r.summary);
// Title and body are separate: summary_title is a one-line
// conclusion, summary is the body text.
const summaryTitle = stringOr(r.summary_title);
// The Model actually used for this evaluation run (paired with provider):
// charted curves are split into series by model, each with a distinct color.
const modelId = stringOr(r.model_id);
const provider = stringOr(r.provider);
const version = numberOr(r.version);
const cost = numberOr(r.cost);
const durationMs = numberOr(r.duration_ms);
return {
time,
...(summaryTitle !== undefined ? { summaryTitle } : {}),
...(summary !== undefined ? { summary } : {}),
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
score,
...(version !== undefined ? { version } : {}),
...(cost !== undefined ? { cost } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
cases,
};
}
export class BenchmarkService {
constructor(private readonly root: string) {}
async list(projectId: string, agentId: string): Promise<BenchmarksResponse> {
const dir = benchmarksDir(this.root, projectId, agentId);
let items: Array<{ name: string; isDir: boolean }>;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
items = entries.map((e) => ({ name: e.name, isDir: e.isDirectory() }));
} catch {
return { benchmarks: [] }; // Doesn't exist when unconfigured.
}
const benchmarks: BenchmarkSummary[] = [];
for (const item of items.filter((i) => i.isDir).sort((a, b) => a.name.localeCompare(b.name))) {
benchmarks.push(await this.readBenchmark(path.join(dir, item.name), item.name));
}
return { benchmarks };
}
private async readBenchmark(benchDir: string, id: string): Promise<BenchmarkSummary> {
// benchmark_config.toml: title, description, and per-case run count (falls back
// to defaults if corrupt). The model isn't part of the config — each evaluation
// carries the Model actually used for that run.
let title = id;
let description: string | undefined;
let runs: number | undefined;
try {
const config = asRecord(
parseToml(await fs.readFile(path.join(benchDir, "benchmark_config.toml"), "utf8")),
);
if (typeof config.title === "string" && config.title !== "") title = config.title;
if (typeof config.description === "string" && config.description !== "") {
description = config.description;
}
const configRuns = numberOr(config.runs);
if (configRuns !== undefined && Number.isInteger(configRuns) && configRuns >= 1) {
runs = configRuns;
}
} catch {
// Missing or corrupt: title falls back to the directory name.
}
// scoreboard.yaml: evaluations[] is appended over time; bad entries are dropped one by one.
let evaluations: BenchmarkEvaluation[] = [];
try {
const scoreboard = asRecord(
parseYaml(await fs.readFile(path.join(benchDir, "scoreboard.yaml"), "utf8")),
);
if (Array.isArray(scoreboard.evaluations)) {
evaluations = scoreboard.evaluations
.map(toEvaluation)
.filter((e): e is BenchmarkEvaluation => e !== null);
}
} catch {
// No scores yet.
}
// Case count: number of case subfolders (the statement/rubric structure isn't validated here).
let caseCount = 0;
try {
const entries = await fs.readdir(benchDir, { withFileTypes: true });
caseCount = entries.filter((e) => e.isDirectory()).length;
} catch {
// Stays at 0.
}
return {
id,
title,
...(description !== undefined ? { description } : {}),
...(runs !== undefined ? { runs } : {}),
caseCount,
evaluations,
};
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* id rules: user_id / project_id / agent_id are semantic ids
* chosen by their creator at creation time — starting with a lowercase letter,
* containing only lowercase letters, digits, and underscores. The id doubles as the
* directory name, so keeping it all-lowercase avoids directory name collisions on
* case-insensitive filesystems (e.g. macOS) at the source.
* The hyphen is a **reserved separator**: it only appears at the join point of a
* non-admin project_id's "<username>-<suffix>" concatenation. Usernames never
* contain a hyphen, so the first hyphen is the ownership boundary — no username can
* ever be crafted to collide with another user's prefix, keeping namespaces
* non-overlapping. session_id and temporary workspace ids are still generated by
* the server (randomHex8).
*/
import { randomBytes } from "node:crypto";
/** 8-character lowercase hex random string (used for server-generated ids like session_id / temporary workspace). */
export function randomHex8(): string {
return randomBytes(4).toString("hex");
}
/** General semantic id rule: starts with a lowercase letter, followed by lowercase letters, digits, or underscores only, 2-64 chars (no hyphen). */
export const SEMANTIC_ID_PATTERN = /^[a-z][a-z0-9_]{1,63}$/;
/** Username tightens the general rule to 2-32 chars: leaves headroom for the default Project id `<username>-default_project`. */
export const USERNAME_PATTERN = /^[a-z][a-z0-9_]{1,31}$/;
/** Suffix segment of a non-admin project_id (after `<username>-`): lowercase letters, digits, and underscores only. */
export const PROJECT_SUFFIX_PATTERN = /^[a-z0-9_]+$/;
/** Upper bound on total project_id length (username <=32 + separator + suffix). */
export const PROJECT_ID_MAX_LENGTH = 64;
/** Human-readable description of the rule (reused in error messages). */
export const SEMANTIC_ID_RULE = "小写字母开头,仅小写字母、数字与下划线";
@@ -0,0 +1,498 @@
/**
* `.project_config.toml` read/write (single hidden config file).
*
* Doesn't reuse core's loadProjectConfig/saveProjectConfig (they only keep known
* fields): reads and writes the complete object directly via smol-toml, preserving
* extension fields like `name`. credential (api_key / base_url / created_at) is
* **inlined on the model entry** — there's no longer a supplementary section or
* secrets file; since the file contains secrets, it's always written with mode
* 0600. Plaintext only ever hits disk, and is always masked in responses.
*
* Model references are **fully split into separate fields**: an entry is
* stored as two independent fields, `provider` and `model_id`; the `(provider,
* model_id)` pair is the entry's unique key. `model_id` is the upstream request id,
* sent to AgentHub verbatim — string concatenation like `<provider>/<id>` is
* forbidden everywhere in the pipeline. `default_model` / `vision_model` are `{
* provider, model_id }` paired references (TOML tables).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import {
GenerativeModel,
catalogEntryFor,
defaultProjectConfig,
projectConfigPath,
renderProjectConfigToml,
resolveModelEnv,
userText,
} from "@prismshadow/penguin-core";
import type { ModelRef } from "@prismshadow/penguin-core";
import type {
ModelInfo,
ModelPricingDto,
ModelRefDto,
ModelsResponse,
ModelsUpdateRequest,
ModelTestRequest,
ModelTestResponse,
} from "../api/types.js";
import { badRequest } from "../http/validate.js";
import type { PricingRates } from "./usage-service.js";
type RawTable = Record<string, unknown>;
/**
* API key masking: length <=12 -> `***`, otherwise `first4…last4`; plaintext is
* never sent to the client. The 12-char threshold: `first4…last4` exposes 8
* characters, which for a 9-12 character short secret would leak more than half of
* it, so those are masked in full instead.
*/
export function maskApiKey(key: string): string {
if (key.length <= 12) return "***";
return `${key.slice(0, 4)}…${key.slice(-4)}`;
}
function asTable(v: unknown): RawTable {
return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as RawTable) : {};
}
function asArray(v: unknown): RawTable[] {
return Array.isArray(v) ? v.map(asTable) : [];
}
function optNum(v: unknown): number | undefined {
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
}
function optStr(v: unknown): string | undefined {
return typeof v === "string" && v !== "" ? v : undefined;
}
/** Leniently reads a paired reference table (default_model / vision_model); returns undefined on a shape mismatch (including the old string format). */
function optRef(v: unknown): ModelRef | undefined {
const t = asTable(v);
const provider = optStr(t.provider);
const modelId = optStr(t.model_id);
return provider !== undefined && modelId !== undefined
? { provider, model_id: modelId }
: undefined;
}
/** Whether an entry matches a paired reference (the entry's provider / model_id fields must be strings). */
function entryMatches(m: RawTable, provider: string, modelId: string): boolean {
return m.provider === provider && m.model_id === modelId;
}
/** In-process Map/Set key for a paired reference (\0-separated to avoid concatenation ambiguity; never persisted, not an id format). */
function refKey(provider: string, modelId: string): string {
return `${provider}\0${modelId}`;
}
/** Display form of a paired reference (for error messages; display only, not a storage format). */
function showRef(provider: string, modelId: string): string {
return `(provider=${provider}, model_id=${modelId})`;
}
export class ProjectConfigService {
constructor(private readonly root: string) {}
private filePath(projectId: string): string {
return projectConfigPath(this.root, projectId);
}
/** Reads the raw TOML object; returns an empty object if the file doesn't exist (does not write to disk). */
async readRaw(projectId: string): Promise<RawTable> {
let raw: string;
try {
raw = await fs.readFile(this.filePath(projectId), "utf8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
throw err;
}
return asTable(parseToml(raw));
}
/**
* Writes the whole object to disk: the file inlines secrets like api_key, always
* written with mode 0600 (the `mode` option only applies at creation time, so
* chmod is used to enforce it on existing files too — matching core's
* saveProjectConfig behavior).
*/
async writeRaw(projectId: string, data: RawTable): Promise<void> {
const file = this.filePath(projectId);
await fs.mkdir(path.dirname(file), { recursive: true });
// Rendering goes through core's single writer: paired references become inline
// tables, models is placed last — matching the CLI's output format exactly
// (the same file should never have two formats).
await fs.writeFile(file, renderProjectConfigToml(data), { encoding: "utf8", mode: 0o600 });
await fs.chmod(file, 0o600);
}
/**
* Initial config for a newly created Project: display name + preset built-in
* model catalog (the default model and all preset entries, sourced from the same
* core defaultProjectConfig; a gateway model's base_url is already inlined on the
* entry, with no key); users only need to fill in an API key as needed (leave it
* blank to fall back to the provider's environment variable).
*/
async writeInitialConfig(projectId: string, name: string): Promise<void> {
const preset = defaultProjectConfig();
await this.writeRaw(projectId, {
name,
...(preset.default_model !== undefined ? { default_model: preset.default_model } : {}),
models: preset.models,
});
}
/**
* Backfills preset models (for onboarding an existing Project, e.g. the
* `default_project` shared with the CLI when the first user is onboarded — its
* directory already existed and never went through `writeInitialConfig`, so it
* previously had no models and no default model).
*
* **Only backfills when there are no models at all**: a Project that already has
* models configured (via the CLI or edited by the user) is left as-is, and its
* other fields (name, etc.) are preserved too — existing config is never
* overwritten.
*/
async ensurePresetModels(projectId: string): Promise<void> {
const raw = await this.readRaw(projectId);
if (asArray(raw.models).length > 0) return;
const preset = defaultProjectConfig();
await this.writeRaw(projectId, {
...raw,
// Also reset to the preset default_model if the existing one points at a now-deleted model, to keep the default model valid.
...(preset.default_model !== undefined ? { default_model: preset.default_model } : {}),
models: preset.models,
});
}
/** Project display name (the toml's name; returns undefined if unset, the frontend falls back to displaying the id). */
async getName(projectId: string): Promise<string | undefined> {
const raw = await this.readRaw(projectId);
return typeof raw.name === "string" ? raw.name : undefined;
}
/** Paired reference of the default Model; returns undefined if unconfigured (or in the old string format). */
async getDefaultModelRef(projectId: string): Promise<ModelRef | undefined> {
const raw = await this.readRaw(projectId);
return optRef(raw.default_model);
}
/** Pricing lookup for usage-recorder: the current pricing for this paired reference (undefined if none -> cost is NULL). */
async getPricing(
projectId: string,
provider: string,
modelId: string,
): Promise<PricingRates | undefined> {
const raw = await this.readRaw(projectId);
const entry = asArray(raw.models).find((m) => entryMatches(m, provider, modelId));
const pricing = entry ? asTable(entry.pricing) : {};
const cacheRead = optNum(pricing.cache_read);
const cacheWrite = optNum(pricing.cache_write);
const output = optNum(pricing.output);
if (cacheRead === undefined && cacheWrite === undefined && output === undefined) {
return undefined;
}
return { cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0, output: output ?? 0 };
}
/**
* Model connectivity test: the model reference `(provider, modelId)` is submitted
* as a pair in the request body; sends one minimal request using that model's
* config (optionally overridden with an unsaved apiKey / baseUrl) — no tools, no
* system prompt, thinking disabled, a tiny output cap, 20s timeout — just to see
* whether it completes normally. The model id sent to AgentHub is `modelId`
* itself (the upstream id verbatim; client_type inference follows it).
*
* Never throws: the LLM layer collapses auth/parameter/network errors into an
* `LLMOutcome`, which is translated here into ok / message. Consumes very few
* Tokens (single-digit output), and writes no Trace and records no usage.
*/
async testModel(projectId: string, req: ModelTestRequest): Promise<ModelTestResponse> {
const raw = await this.readRaw(projectId);
// Testable even if the model isn't in the config yet (validate before saving when adding a custom model): in that case all parameters come from the request body.
const entry = asArray(raw.models).find((m) => entryMatches(m, req.provider, req.modelId)) ?? {};
// Always tests against the **current form draft**: checking "clear" means the saved key is not fallen back to; an explicit null base URL is treated as cleared.
const savedKey = optStr(entry.api_key);
const apiKey = req.clearApiKey ? undefined : (req.apiKey ?? savedKey);
const savedBaseUrl = optStr(entry.base_url);
const baseUrl = req.baseUrl === null ? undefined : (req.baseUrl ?? savedBaseUrl);
const clientType = req.clientType ?? optStr(entry.client_type);
const startedAt = Date.now();
try {
// Construction must be inside the try block: the underlying provider SDK can
// throw during **client construction** itself when a credential is missing
// (models on the OpenAI protocol need apiKey/OPENAI_API_KEY) — the whole point
// of a connectivity test is to collapse that kind of failure into
// `{ ok:false }`; if construction were outside the try, a missing-key test
// would bubble up as a 500.
const llm = new GenerativeModel({
modelId: req.modelId,
...(apiKey ? { apiKey } : {}),
...(baseUrl ? { baseUrl } : {}),
...(clientType ? { clientType } : {}),
tools: [],
thinkingLevel: "none",
maxTokens: 16,
requestTimeoutMs: 20_000,
});
const gen = llm.streamGenerate({ newMessages: [userText("ping")] });
for (;;) {
const step = await gen.next();
if (step.done) {
const outcome = step.value;
if (outcome.status === "completed")
return { ok: true, latencyMs: Date.now() - startedAt };
const detail = "message" in outcome && outcome.message ? outcome.message : outcome.status;
return { ok: false, message: String(detail).slice(0, 300) };
}
}
} catch (err) {
// Defensive: an unexpected exception during construction/iteration (the LLM layer promises not to throw; this is a fallback).
return {
ok: false,
message: (err instanceof Error ? err.message : String(err)).slice(0, 300),
};
}
}
/**
* GET models view: masks credential (inline fields), flags the default Model;
* the group is the entry's `provider` field, looked up in the built-in catalog by
* the `(provider, model_id)` pair to fill in displayName / envKey (entries outside
* the catalog are treated as custom models: envKey only has a fallback for the
* openai protocol). vision follows the TOML annotation when present, otherwise
* falls back to the catalog annotation (if neither exists, the field is omitted =
* supported by default).
*/
async getModels(projectId: string): Promise<ModelsResponse> {
const raw = await this.readRaw(projectId);
const defaultRef = optRef(raw.default_model);
const visionRef = optRef(raw.vision_model);
const models: ModelInfo[] = asArray(raw.models)
// An entry is valid only if both provider and model_id are strings (an entry in the old concatenated format lacks provider and is ignored).
.filter((m) => typeof m.provider === "string" && typeof m.model_id === "string")
.map((m) => {
const provider = m.provider as string;
const modelId = m.model_id as string;
const pricing = asTable(m.pricing);
const pricingDto: ModelPricingDto | undefined =
optNum(pricing.cache_read) !== undefined ||
optNum(pricing.cache_write) !== undefined ||
optNum(pricing.output) !== undefined
? {
cacheRead: optNum(pricing.cache_read) ?? 0,
cacheWrite: optNum(pricing.cache_write) ?? 0,
output: optNum(pricing.output) ?? 0,
}
: undefined;
const clientType = optStr(m.client_type);
const cat = catalogEntryFor(provider, modelId);
// The env fallback is reported as-is: follows the same rule as
// AgentHub routing — an explicit client_type takes priority (the openai
// protocol reads OPENAI_*, independent of the group), otherwise it's
// auto-routed to a provider client based on model_id; an id that can't be
// routed has no fallback (no envKey, and AgentHub will reject that id).
const envKey = resolveModelEnv(modelId, clientType)?.envKey;
const vision = typeof m.vision === "boolean" ? m.vision : cat?.supportsVision;
// Display name: the explicit TOML field (user-edited) takes priority, then the built-in catalog.
const displayName = optStr(m.display_name) ?? cat?.displayName;
// credential is inlined on the entry: a credential block is emitted if either api_key or base_url is present.
const apiKey = optStr(m.api_key);
const credBaseUrl = optStr(m.base_url);
const createdAt = optStr(m.created_at);
const info: ModelInfo = {
provider,
modelId,
...(displayName !== undefined ? { displayName } : {}),
isDefault:
defaultRef !== undefined &&
defaultRef.provider === provider &&
defaultRef.model_id === modelId,
...(optNum(m.context_window) !== undefined
? { contextWindow: optNum(m.context_window)! }
: {}),
...(clientType ? { clientType } : {}),
...(vision !== undefined ? { vision } : {}),
...(envKey ? { envKey } : {}),
...(pricingDto ? { pricing: pricingDto } : {}),
...(apiKey !== undefined || credBaseUrl !== undefined
? {
credential: {
...(apiKey !== undefined ? { apiKeyMasked: maskApiKey(apiKey) } : {}),
...(credBaseUrl !== undefined ? { baseUrl: credBaseUrl } : {}),
...(createdAt !== undefined ? { createdAt } : {}),
},
}
: {}),
};
return info;
});
const toDto = (ref: ModelRef): ModelRefDto => ({
provider: ref.provider,
modelId: ref.model_id,
});
return {
...(defaultRef !== undefined ? { defaultModel: toDto(defaultRef) } : {}),
...(visionRef !== undefined ? { visionModel: toDto(visionRef) } : {}),
models,
};
}
/**
* PUT replaces the whole models table: key =
* `(provider, modelId)`; model entries that no longer appear are deleted along
* with their inline credential; omitting apiKey keeps the existing value,
* providing one overwrites it and records created_at, clearApiKey clears it;
* baseUrl null clears it / omitted keeps it. A key change (either the group or
* the upstream id changes) is migrated as a pair via `renamedFrom`: credential and
* unknown fields migrate along with the base entry, and default/vision pointers
* follow. Other extension fields in the toml (name, etc.) are preserved.
*/
async updateModels(projectId: string, req: ModelsUpdateRequest): Promise<ModelsResponse> {
const raw = await this.readRaw(projectId);
const prevModels = asArray(raw.models);
const seen = new Set<string>();
const nextModels: RawTable[] = [];
// Rename mapping (old reference key -> new reference): default model / vision model pointers follow a key change instead of being lost on a full table replacement.
const renamed = new Map<string, ModelRefDto>();
for (const entry of req.models) {
const key = refKey(entry.provider, entry.modelId);
if (seen.has(key)) {
throw badRequest(
`models 中存在重复的模型引用:${showRef(entry.provider, entry.modelId)}。`,
);
}
seen.add(key);
if (
entry.renamedFrom !== undefined &&
!(
entry.renamedFrom.provider === entry.provider &&
entry.renamedFrom.modelId === entry.modelId
)
) {
renamed.set(refKey(entry.renamedFrom.provider, entry.renamedFrom.modelId), {
provider: entry.provider,
modelId: entry.modelId,
});
}
// Model entry: uses the old entry (the entry for the original reference when
// the key changed) as the base, preserving unknown fields and inline
// credential; known fields are replaced wholesale per the request (omitted
// means removed).
const prevRef = entry.renamedFrom ?? { provider: entry.provider, modelId: entry.modelId };
const prev = prevModels.find((m) => entryMatches(m, prevRef.provider, prevRef.modelId)) ?? {};
const next: RawTable = { ...prev, provider: entry.provider, model_id: entry.modelId };
delete next.context_window;
delete next.client_type;
delete next.vision;
delete next.pricing;
delete next.display_name;
// Leftover key from the old concatenated format (request_model_id): defensively stripped, never written to disk again.
delete next.request_model_id;
// Display name: **only written to disk when it differs from the built-in
// catalog (looked up by the paired reference)** — preset models keep the
// config clean, only user-edited ones (including those not found in the
// catalog) get written into the TOML.
const catNew = catalogEntryFor(entry.provider, entry.modelId);
if (entry.displayName && entry.displayName !== catNew?.displayName) {
next.display_name = entry.displayName;
}
if (entry.contextWindow !== undefined) next.context_window = entry.contextWindow;
if (entry.clientType) next.client_type = entry.clientType;
// Treated as supported by default: only written to disk when explicitly annotated (both true/false are kept; false drives a frontend blocking hint).
if (entry.vision !== undefined) next.vision = entry.vision;
if (entry.pricing !== undefined) {
next.pricing = {
unit: "usd_per_mtok",
cache_read: entry.pricing.cacheRead,
cache_write: entry.pricing.cacheWrite,
output: entry.pricing.output,
};
}
// credential is inlined on the entry; added/removed on top of the old value per the request (migrates automatically with the base entry when the key changes).
if (entry.clearApiKey) {
delete next.api_key;
delete next.created_at;
}
if (entry.apiKey !== undefined) {
next.api_key = entry.apiKey;
next.created_at = new Date().toISOString();
}
if (entry.baseUrl === null) delete next.base_url;
else if (entry.baseUrl !== undefined) next.base_url = entry.baseUrl;
nextModels.push(next);
}
// default_model: when provided it must be present in models; when omitted the previous value is kept (the pointer follows a key rename; if it was deleted, it's removed).
let defaultModel: ModelRefDto | undefined;
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 = req.defaultModel;
} else {
const prevRef = optRef(raw.default_model);
if (prevRef !== undefined) {
const prevKey = refKey(prevRef.provider, prevRef.model_id);
const followed = renamed.get(prevKey) ?? {
provider: prevRef.provider,
modelId: prevRef.model_id,
};
if (seen.has(refKey(followed.provider, followed.modelId))) defaultModel = followed;
}
}
// vision_model: same semantics as default_model; additionally must not be annotated vision=false (can't proxy-read images if unsupported).
const targetOf = (ref: ModelRefDto) =>
req.models.find((m) => m.provider === ref.provider && m.modelId === ref.modelId);
let visionModel: ModelRefDto | undefined;
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)}。`,
);
}
if (targetOf(req.visionModel)?.vision === false) {
throw badRequest(
`visionModel 不能指向标注为不支持图片的模型:${showRef(req.visionModel.provider, req.visionModel.modelId)}。`,
);
}
visionModel = req.visionModel;
} else {
const prevRef = optRef(raw.vision_model);
if (prevRef !== undefined) {
const prevKey = refKey(prevRef.provider, prevRef.model_id);
const followed = renamed.get(prevKey) ?? {
provider: prevRef.provider,
modelId: prevRef.model_id,
};
if (seen.has(refKey(followed.provider, followed.modelId))) {
// The former vision model is now annotated as not supporting images: the annotation takes priority, and the pointer is dropped as invalid.
if (targetOf(followed)?.vision !== false) visionModel = followed;
}
}
}
const toRaw = (ref: ModelRefDto): RawTable => ({
provider: ref.provider,
model_id: ref.modelId,
});
const next: RawTable = { ...raw, models: nextModels };
if (defaultModel !== undefined) next.default_model = toRaw(defaultModel);
else delete next.default_model;
if (visionModel !== undefined) next.vision_model = toRaw(visionModel);
else delete next.vision_model;
await this.writeRaw(projectId, next);
return this.getModels(projectId);
}
}
@@ -0,0 +1,350 @@
/**
* Project service.
*
* The single implementation point for authorization rules: `requireProjectAccess`
* (owner or member, otherwise 404 without leaking existence) and
* `requireProjectOwner` (owner only; 403 when known to be accessible, 404 when not)
* are reused by every route; the non-throwing `canAccess` (for error attribution)
* is likewise just a sibling wrapper around them — all three share the single
* `resolveAccess` decision, with no second rule set maintained separately.
* Also handles Project create / list / delete, member authorization, and initial
* Project provisioning at signup.
*/
import fs from "node:fs/promises";
import { DEFAULT_PROJECT_ID, projectDir, provisionProjectAgents } from "@prismshadow/penguin-core";
import type { MemberInfo, ProjectRole, ProjectSummary } from "../api/types.js";
import { HttpError } from "../http/errors.js";
import type { AgentsRepo } from "../db/repos/agents.js";
import type { ErrorsRepo } from "../db/repos/errors.js";
import type { MembersRepo } from "../db/repos/members.js";
import type { ProjectRow, ProjectsRepo } from "../db/repos/projects.js";
import type { SessionsRepo } from "../db/repos/sessions.js";
import type { SchedulesRepo } from "../db/repos/schedules.js";
import type { UsageRepo } from "../db/repos/usage.js";
import type { UserRow, UsersRepo } from "../db/repos/users.js";
import type { SessionManager } from "../runtime/session-manager.js";
import {
PROJECT_ID_MAX_LENGTH,
PROJECT_SUFFIX_PATTERN,
SEMANTIC_ID_PATTERN,
SEMANTIC_ID_RULE,
} from "./ids.js";
import type { ProjectConfigService } from "./project-config-service.js";
/** Fallback timeout for waiting on runs to settle before deleting a Project. */
const ABORT_SETTLE_TIMEOUT_MS = 5000;
async function dirExists(path: string): Promise<boolean> {
try {
const stat = await fs.stat(path);
return stat.isDirectory();
} catch {
return false;
}
}
export interface ProjectServiceDeps {
root: string;
users: UsersRepo;
projects: ProjectsRepo;
members: MembersRepo;
agents: AgentsRepo;
sessions: SessionsRepo;
usage: UsageRepo;
errors: ErrorsRepo;
schedules: SchedulesRepo;
projectConfig: ProjectConfigService;
manager: SessionManager;
}
export class ProjectService {
constructor(private readonly deps: ProjectServiceDeps) {}
// —— Authorization rules (single implementation point) ——
/**
* The **sole** implementation of the owner / member check: returns the row with
* a role if accessible, otherwise null. `requireProjectAccess` below (throws 404)
* and `canAccess` (returns boolean) are both just wrappers around it — there's
* only one copy of the decision rule, since writing it twice would eventually
* drift out of sync.
*/
private resolveAccess(
userId: string,
projectId: string,
): (ProjectRow & { role: ProjectRole }) | null {
const row = this.deps.projects.findById(projectId);
if (!row) return null;
if (row.ownerUserId === userId) return { ...row, role: "owner" };
if (this.deps.members.isMember(projectId, userId)) return { ...row, role: "member" };
return null;
}
/** Accessible by owner or member; otherwise 404 (does not leak Project existence). */
requireProjectAccess(userId: string, projectId: string): ProjectRow & { role: ProjectRole } {
const row = this.resolveAccess(userId, projectId);
if (!row) {
throw new HttpError(404, "project_not_found", "Project 不存在或无权访问。");
}
return row;
}
/**
* The non-throwing version of the same check: used for **error attribution**
* (app.onError) — that runs on the error-handling path, where throwing another
* 404 would only break error handling; whether access is granted shouldn't be
* expressed as an exception there anyway.
*/
canAccess(userId: string, projectId: string): boolean {
return this.resolveAccess(userId, projectId) !== null;
}
/** Owner only: 403 when known accessible as a member; 404 when not accessible. */
requireProjectOwner(userId: string, projectId: string): ProjectRow {
const row = this.requireProjectAccess(userId, projectId);
if (row.role !== "owner") {
throw new HttpError(403, "owner_required", "该操作仅 Project owner 可执行。");
}
return row;
}
/** List of project_ids accessible to the current user (owned + granted access) (used by workspace-guard). */
accessibleProjectIds(userId: string): string[] {
return this.deps.projects.listAccessible(userId).map((p) => p.projectId);
}
// —— Project lifecycle ——
/** List of owned + granted-access Projects; display names are read from each project_config.toml. */
async listProjects(userId: string): Promise<ProjectSummary[]> {
const rows = this.deps.projects.listAccessible(userId);
return Promise.all(
rows.map(async (row) => {
const name = await this.deps.projectConfig.getName(row.projectId);
return {
projectId: row.projectId,
...(name !== undefined ? { name } : {}),
role: row.role,
ownerUserId: row.ownerUserId,
createdAt: row.createdAt,
};
}),
);
}
/**
* Create a Project: the id is chosen by the creator (a semantic id, checked for
* duplicates against both the DB and the directory — 409 if taken), the initial
* config is written (display name defaults to the id), and the built-in Agent is
* initialized.
* A non-admin's id is forced to be "<username>-<suffix>", where the suffix is
* lowercase letters, digits, and underscores only — the hyphen is a reserved
* separator, usernames never contain a hyphen, so the first hyphen is the
* ownership boundary and the prefix can never be crafted from another username;
* an admin's id contains no hyphen (occupying no user's namespace).
*/
async createProject(owner: UserRow, projectId: string, name?: string): Promise<ProjectSummary> {
if (owner.isAdmin) {
if (!SEMANTIC_ID_PATTERN.test(projectId)) {
throw new HttpError(
400,
"invalid_project_id",
`Project id 须为 2~64 位:${SEMANTIC_ID_RULE}(连字符保留作用户命名空间分隔)。`,
);
}
} else {
const prefix = `${owner.userId}-`;
const suffix = projectId.startsWith(prefix) ? projectId.slice(prefix.length) : "";
if (!PROJECT_SUFFIX_PATTERN.test(suffix) || projectId.length > PROJECT_ID_MAX_LENGTH) {
throw new HttpError(
400,
"project_id_prefix_required",
`Project id 须以 ${prefix} 开头,后接小写字母、数字或下划线(总长不超过 ${PROJECT_ID_MAX_LENGTH})。`,
);
}
}
if (
this.deps.projects.findById(projectId) !== null ||
(await dirExists(projectDir(this.deps.root, projectId)))
) {
throw new HttpError(409, "project_exists", `Project id 已被占用:${projectId}。`);
}
const displayName = name ?? projectId;
const createdAt = new Date().toISOString();
// Insert the DB row first: the primary key is the final arbiter for concurrent
// creation with the same id (the duplicate check above has an await gap), and a
// conflict is mapped to 409 with **no cleanup** — the directory belongs to the
// winner, and cleaning up here would wrongly delete the other side's data.
try {
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 err;
}
// If file initialization fails, roll back the DB row and clean up the
// directory: an orphaned directory would make retries with this id 409 forever
// (a typical scenario: signup failure rolled back the user row, but the
// <username>-default_project directory was left behind).
try {
await fs.mkdir(projectDir(this.deps.root, projectId), { recursive: true });
await this.deps.projectConfig.writeInitialConfig(projectId, displayName);
await this.provisionBuiltinAgents(projectId);
} catch (err) {
this.deps.projects.delete(projectId);
await fs
.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true })
.catch(() => {});
throw err;
}
return {
projectId,
name: displayName,
role: "owner",
ownerUserId: owner.userId,
createdAt,
};
}
/**
* Initial Project provisioned at signup:
* the built-in admin adopts `default_project` (if the directory already exists,
* it's adopted directly without overwriting existing config — shared with the
* CLI); other users get `<username>-default_project` created, with display name
* defaulting to the username.
*/
async provisionInitialProject(user: UserRow, isAdmin: boolean): Promise<void> {
if (!isAdmin) {
await this.createProject(user, `${user.userId}-${DEFAULT_PROJECT_ID}`, user.userId);
return;
}
const projectId = DEFAULT_PROJECT_ID;
// Initialize the built-in Agent (loaded without overwriting if it already exists); this also ensures the directory exists.
await this.provisionBuiltinAgents(projectId);
// Adopting an existing directory doesn't go through writeInitialConfig: preset
// models and the default model are backfilled instead (only when there are no
// models at all; a default_project already configured via the CLI is left
// as-is).
await this.deps.projectConfig.ensurePresetModels(projectId);
this.deps.projects.insert({
projectId,
ownerUserId: user.userId,
createdAt: new Date().toISOString(),
});
}
/**
* Delete a Project (owner): default_project is refused; deleting the user's
* **last accessible Project** is refused too (deleting it would leave the list
* empty, with no Project to select in the Web client and the page stuck on a
* skeleton screen — a typical case being a non-first user deleting the initial
* Project provisioned at signup); active runs are drained first, then the DB and
* directory are cleared.
*/
async deleteProject(userId: string, projectId: string): Promise<void> {
this.requireProjectOwner(userId, projectId);
if (projectId === DEFAULT_PROJECT_ID) {
throw new HttpError(
409,
"cannot_delete_default_project",
"default_project 与 CLI 共用,不能从 Web 删除。",
);
}
if (this.deps.projects.listAccessible(userId).length <= 1) {
throw new HttpError(
409,
"cannot_delete_last_project",
"这是当前账号最后一个 Project,删除后将无 Project 可用;请先创建新的 Project。",
);
}
await this.destroyProject(projectId);
}
/**
* The actual deletion (no authorization or protection checks): shared by
* deleteProject and the cascade cleanup when an admin deletes a user.
* Abort follow-up (writing the abort event to Trace, etc.) happens
* asynchronously: waits for runs to settle (capped at 5s) before deleting the
* directory, to avoid the Trace writer recreating the directory after deletion.
*/
async destroyProject(projectId: string): Promise<void> {
const runnings = this.deps.manager.abortProject(projectId);
if (runnings.length > 0) {
await Promise.race([
Promise.allSettled(runnings).then(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, ABORT_SETTLE_TIMEOUT_MS).unref?.()),
]);
}
this.deps.projects.delete(projectId); // project_members cascade-deleted
this.deps.agents.deleteByProject(projectId);
this.deps.sessions.deleteByProject(projectId);
this.deps.usage.deleteByProject(projectId);
this.deps.errors.deleteByProject(projectId);
this.deps.schedules.deleteByProject(projectId);
await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true });
}
// —— Member authorization ——
/** Member list: owner (role=owner) + members. */
listMembers(userId: string, projectId: string): MemberInfo[] {
const project = this.requireProjectAccess(userId, projectId);
const members = this.deps.members.list(projectId);
return [
{ userId: project.ownerUserId, role: "owner", createdAt: project.createdAt },
...members.map((m) => ({
userId: m.userId,
role: "member" as const,
createdAt: m.createdAt,
})),
];
}
/** Grant member access (owner): invites by username; 404 if the user doesn't exist, 409 for the owner themself or an existing member. */
addMember(userId: string, projectId: string, targetUserId: string): MemberInfo {
const project = this.requireProjectOwner(userId, projectId);
const target = this.deps.users.findById(targetUserId);
if (!target) {
throw new HttpError(404, "user_not_found", `用户不存在:${targetUserId}。`);
}
if (target.userId === project.ownerUserId) {
throw new HttpError(409, "already_owner", "owner 无需授权给自己。");
}
if (this.deps.members.isMember(projectId, target.userId)) {
throw new HttpError(409, "already_member", `${targetUserId} 已是该 Project 的成员。`);
}
const createdAt = new Date().toISOString();
this.deps.members.insert({ projectId, userId: target.userId, createdAt });
return { userId: target.userId, role: "member", createdAt };
}
/** Revoke member access (owner). */
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}。`);
}
this.deps.members.delete(projectId, targetUserId);
}
/**
* Ensures the Project's built-in Agent exists (the sole built-in Agent
* default_agent; initialized if the directory is empty, otherwise loaded without
* overwriting) and indexes it. createdAt increments by 1ms in preset order, so
* built-in Agents stably sort first; other Agents backfilled by directory
* scanning are sorted by their own createdAt and are outside the scope of this
* guarantee.
*/
private async provisionBuiltinAgents(projectId: string): Promise<void> {
const agentIds = await provisionProjectAgents({ root: this.deps.root, projectId });
const base = Date.now();
agentIds.forEach((agentId, i) => {
this.deps.agents.insertOrIgnore({
projectId,
agentId,
createdAt: new Date(base + i).toISOString(),
});
});
}
}
@@ -0,0 +1,302 @@
/**
* Session index service.
*
* The list is DB index ∪ Trace directory discovery: scans
* `<agent>/traces/<date>/<session_id>_<index3>.jsonl`; an unmanaged Session (e.g.
* one started via the CLI) has its first line's session_meta read for
* (provider, model_id) / workspace, which is backfilled into a DB row
* (approval_mode defaults, createdAt is taken from the timestamp embedded in
* session_id).
* Create: via core's `agent.createSession` (model reference as a provider + modelId
* pair; defaults to the Project's default reference, 400 if there is none; omitting
* provider goes through resolveModelRef for unique resolution); the new Session is
* added to session-manager's active table (state idle).
*/
import path from "node:path";
import { readdir } from "node:fs/promises";
import {
createAgent,
isSessionMeta,
readTraceTolerant,
tracesDir,
} from "@prismshadow/penguin-core";
import type { ApprovalMode, SessionInfo } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js";
import type { SessionManager } from "../runtime/session-manager.js";
import type { ProjectConfigService } from "./project-config-service.js";
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
const SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/;
/** Derives creation time from the local timestamp embedded in session_id; returns null if it doesn't match. */
export function sessionIdCreatedAt(sessionId: string): string | null {
const m = SESSION_ID_TS_RE.exec(sessionId);
if (!m) return null;
const [, y, mo, d, h, mi, s] = m;
const date = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
export interface SessionServiceDeps {
root: string;
sessions: SessionsRepo;
manager: SessionManager;
projectConfig: ProjectConfigService;
}
export class SessionService {
constructor(private readonly deps: SessionServiceDeps) {}
/** DB row -> SessionInfo (run status and pending approval count come from session-manager). */
toInfo(row: SessionRow, hasTrace: boolean): SessionInfo {
return {
sessionId: row.sessionId,
projectId: row.projectId,
agentId: row.agentId,
provider: row.provider,
modelId: row.modelId,
workspace: row.workspace,
approvalMode: row.approvalMode,
...(row.title !== null ? { title: row.title } : {}),
...(row.source != null ? { source: row.source } : {}),
createdAt: row.createdAt,
status: this.deps.manager.statusOf(row.sessionId),
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
hasTrace,
archived: (row.archivedAt ?? null) !== null,
};
}
/** Whether this Session already has a Trace record (a Task has been run). */
async hasTrace(row: SessionRow): Promise<boolean> {
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
return ids.has(row.sessionId);
}
/** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */
async listSessions(projectId: string, agentId: string): Promise<SessionInfo[]> {
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
const rows = new Map(
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
);
// Unmanaged Trace Sessions: backfill an index row by reading the first line's session_meta.
for (const sessionId of traceIds) {
if (rows.has(sessionId)) continue;
const discovered = await this.adoptTraceSession(projectId, agentId, sessionId);
if (discovered) rows.set(sessionId, discovered);
}
return [...rows.values()]
.sort(
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
)
.map((row) => this.toInfo(row, traceIds.has(row.sessionId)));
}
/**
* Session stats (Agents list card): total count = size of the union of DB index
* ∪ Trace directory discovery; activity = number of active Sessions per day over
* the last `days` days (deduplicated count of Sessions created that day or with a
* Trace record that day; index 0 = earliest, last index = today). Counts only —
* does not backfill index rows.
*/
async sessionStats(
projectId: string,
agentId: string,
days: number,
): Promise<{ sessionCount: number; activity: number[] }> {
const all = new Set<string>();
const byDate = new Map<string, Set<string>>();
const mark = (date: string, sessionId: string): void => {
all.add(sessionId);
const set = byDate.get(date) ?? new Set<string>();
set.add(sessionId);
byDate.set(date, set);
};
// Trace directory: the date directory name is the local date (yyyy-mm-dd) that core uses when writing to disk.
const dir = tracesDir(this.deps.root, projectId, agentId);
for (const dateDir of await listDirsSafe(dir)) {
for (const file of await listFilesSafe(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file);
if (match) mark(dateDir, match[1]!);
}
}
// DB index: the creation day also counts as active (a Session that hasn't run a Task yet produces no Trace).
for (const row of this.deps.sessions.listByAgent(projectId, agentId)) {
const created = new Date(row.createdAt);
if (Number.isNaN(created.getTime())) all.add(row.sessionId);
else mark(localDate(created), row.sessionId);
}
const activity: number[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - i);
activity.push(byDate.get(localDate(d))?.size ?? 0);
}
return { sessionCount: all.size, activity };
}
/**
* Create a Session: model reference `(provider, modelId)` as a pair; defaults to
* the Project's default reference (400 prompting to configure a model first if
* there is none); when provider is omitted, core's resolveModelRef performs
* unique resolution (400 on zero matches / ambiguity). `workspace` is already
* validated by the route guard. The new Session is added to the active table
* (idle).
*/
async createSession(args: {
projectId: string;
agentId: string;
/** Upstream id of the session's model (paired with provider); defaults to the Project's default reference. */
modelId?: string;
/** The provider group for `modelId`; if omitted, resolveModelRef performs unique resolution. */
provider?: string;
workspace?: string;
approvalMode?: ApprovalMode;
/** Session source marker (schedule when triggered by a scheduled task; defaults to user-created). */
source?: "schedule";
}): Promise<SessionInfo> {
let modelId = args.modelId;
let provider = args.provider;
if (modelId === undefined) {
const def = await this.deps.projectConfig.getDefaultModelRef(args.projectId);
if (def === undefined) {
throw new HttpError(
400,
"no_default_model",
"该 Project 尚未配置默认模型,请先在「模型」页添加模型并设为默认。",
);
}
modelId = def.model_id;
provider = def.provider;
}
const agent = await createAgent({
root: this.deps.root,
projectId: args.projectId,
agentId: args.agentId,
});
let session;
try {
session = await agent.createSession({
modelId,
...(provider !== undefined ? { provider } : {}),
...(args.workspace !== undefined ? { workspaceDir: args.workspace } : {}),
});
} catch (err) {
// A missing credential is its own category (the frontend shows localized text
// by code); other core errors (zero matches / ambiguous reference, Workspace
// not existing, etc.) are collapsed to 400 — the guard already blocks most cases.
if (isMissingCredential(err)) throw modelCredentialMissing(modelId);
throw new HttpError(
400,
"session_create_failed",
err instanceof Error ? err.message : String(err),
);
}
const row: SessionRow = {
sessionId: session.sessionId,
projectId: args.projectId,
agentId: args.agentId,
provider: session.provider,
modelId: session.modelId,
workspace: session.workspaceDir,
approvalMode: args.approvalMode ?? "allow-all",
title: null,
createdAt: new Date().toISOString(),
...(args.source !== undefined ? { source: args.source } : {}),
};
this.deps.sessions.insert(row);
this.deps.manager.adopt(row, session);
return this.toInfo(row, false);
}
/** Scans the Trace directory to get the set of session_ids with records. */
private async discoverTraceSessionIds(projectId: string, agentId: string): Promise<Set<string>> {
const dir = tracesDir(this.deps.root, projectId, agentId);
const ids = new Set<string>();
for (const dateDir of await listDirsSafe(dir)) {
for (const file of await listFilesSafe(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file);
if (match) ids.add(match[1]!);
}
}
return ids;
}
/** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */
private async adoptTraceSession(
projectId: string,
agentId: string,
sessionId: string,
): Promise<SessionRow | null> {
const dir = tracesDir(this.deps.root, projectId, agentId);
let earliest: { path: string; index: number } | null = null;
for (const dateDir of await listDirsSafe(dir)) {
for (const file of await listFilesSafe(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file);
if (!match || match[1] !== sessionId) continue;
const index = Number(match[2]);
if (!earliest || index < earliest.index) {
earliest = { path: path.join(dir, dateDir, file), index };
}
}
}
if (!earliest) return null;
let messages;
try {
messages = await readTraceTolerant(earliest.path);
} catch {
return null; // Corrupt file: skip (does not block the list)
}
const meta = messages.find(isSessionMeta);
if (!meta) return null;
// An older Trace version's session_meta lacks provider (the model reference
// wasn't split into separate fields yet): no backward compat, skip adoption
// (core will give a clear error on resume; the product hasn't launched yet, so
// old data can simply be deleted and recreated).
if (typeof meta.payload.provider !== "string") return null;
const row: SessionRow = {
sessionId,
projectId,
agentId,
provider: meta.payload.provider,
modelId: meta.payload.model_id,
workspace: meta.payload.workspace,
// The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value.
approvalMode: "allow-all",
title: null,
createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp,
};
// Idempotent backfill: concurrent list calls may discover the same Session for the first time simultaneously (consistent with AgentsRepo's convention).
this.deps.sessions.insertOrIgnore(row);
return row;
}
}
/** Local date as yyyy-mm-dd (matches the Trace date directory convention: core's internal formatLocalDate, not publicly exported). */
function localDate(d: Date): string {
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
async function listDirsSafe(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
async function listFilesSafe(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
@@ -0,0 +1,190 @@
/**
* Agent State version snapshots and export/import.
*
* A snapshot = `snapshots/v<version>.tar.gz`, packaging `agent_state/` (archive
* entries are rooted at `agent_state/`), **excluding `.vault.toml`** (secrets never
* go into a snapshot); if a snapshot for the same version already exists, it isn't
* repacked. Import goes by the `version` inside the package and keeps the current
* vault; a snapshot of the current version is automatically taken before import;
* importing a package version equal to or lower than the current version requires
* explicit confirmation (otherwise 409).
* Docs: /docs/self-improvement § "Snapshots and versions".
*/
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import * as tar from "tar";
import { parse as parseYaml } from "yaml";
import {
agentDir,
agentStateDir,
agentStateVersion,
agentVaultPath,
snapshotsDir,
systemConfigPath,
} from "@prismshadow/penguin-core";
import { HttpError } from "../http/errors.js";
import { badRequest } from "../http/validate.js";
/** Vault file name inside a snapshot/import package (used for archive filtering). */
const VAULT_BASENAME = ".vault.toml";
function isVaultEntry(entryPath: string): boolean {
return path.posix.basename(entryPath.replaceAll("\\", "/")) === VAULT_BASENAME;
}
export class SnapshotService {
constructor(private readonly root: string) {}
/** Current Agent State version number (missing field treated as 1); throws 404 if the Agent doesn't exist. */
async currentVersion(projectId: string, agentId: string): Promise<number> {
let raw: string;
try {
raw = await fs.readFile(systemConfigPath(this.root, projectId, agentId), "utf8");
} catch {
throw new HttpError(404, "agent_not_found", "Agent 不存在。");
}
const parsed = parseYaml(raw) as { version?: unknown } | null;
return agentStateVersion({
version: typeof parsed?.version === "number" ? parsed.version : undefined,
});
}
/** Ensures a snapshot exists for the current version (not repacked for the same version), returns the snapshot file path and version number. */
async ensureSnapshot(
projectId: string,
agentId: string,
): Promise<{ version: number; file: string }> {
const version = await this.currentVersion(projectId, agentId);
const dir = snapshotsDir(this.root, projectId, agentId);
const file = path.join(dir, `v${version}.tar.gz`);
try {
await fs.access(file);
return { version, file };
} catch {
// No snapshot for this version yet: pack it.
}
await fs.mkdir(dir, { recursive: true });
const tmp = `${file}.tmp-${randomBytes(4).toString("hex")}`;
await tar.create(
{
gzip: true,
cwd: agentDir(this.root, projectId, agentId),
file: tmp,
portable: true,
filter: (p) => !isVaultEntry(p),
},
["agent_state"],
);
await fs.rename(tmp, file);
return { version, file };
}
/** Export: automatically packs a snapshot first if none exists, returns the info needed for download. */
async exportArchive(
projectId: string,
agentId: string,
): Promise<{ version: number; file: string; fileName: string }> {
const { version, file } = await this.ensureSnapshot(projectId, agentId);
return { version, file, fileName: `${agentId}-v${version}.tar.gz` };
}
/**
* Import: validates the package structure and `version`, compares versions
* (higher than current imports directly, same version or older requires
* `confirm`), automatically snapshots the current version before import, then
* replaces `agent_state/` while keeping the current vault.
*/
async importArchive(
projectId: string,
agentId: string,
archive: Buffer,
confirm: boolean,
): Promise<{ version: number }> {
const current = await this.currentVersion(projectId, agentId);
const base = agentDir(this.root, projectId, agentId);
const staging = path.join(base, `.import-${randomBytes(6).toString("hex")}`);
await fs.mkdir(staging, { recursive: true });
try {
const archiveFile = path.join(staging, "archive.tar.gz");
await fs.writeFile(archiveFile, archive);
const extractDir = path.join(staging, "extracted");
await fs.mkdir(extractDir, { recursive: true });
try {
await tar.extract({
file: archiveFile,
cwd: extractDir,
filter: (p) => !isVaultEntry(p),
});
} catch {
throw badRequest("导入失败:不是合法的 tar.gz 快照包。");
}
// Validation: the package must contain agent_state/system_config.yaml, and version must be valid.
const configPath = path.join(extractDir, "agent_state", "system_config.yaml");
let parsed: unknown;
try {
parsed = parseYaml(await fs.readFile(configPath, "utf8"));
} catch {
throw badRequest("导入失败:包内缺少 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 非法。");
}
const incomingRaw = (parsed as { version?: unknown }).version;
if (
incomingRaw !== undefined &&
(!Number.isInteger(incomingRaw) || (incomingRaw as number) < 1)
) {
throw badRequest("导入失败:包内 version 非法。");
}
const incoming = agentStateVersion({
version: typeof incomingRaw === "number" ? incomingRaw : undefined,
});
if (incoming <= current && !confirm) {
throw new HttpError(
409,
"version_conflict",
`包版本 v${incoming} 不高于当前 v${current},需要确认后导入。`,
);
}
// Automatically snapshots the current version before import (reused if one already exists for that version), so a mistaken import can be rolled back.
await this.ensureSnapshot(projectId, agentId);
// Replace agent_state: first merge the current vault into the staging
// directory to be swapped in (extraction already filtered out any vault
// inside the package, so no conflict), making the replacement a pure rename
// swap — once the swap lands, there's no further write that can fail. The
// vault never goes into a snapshot, so if recovery fails after the swap, the
// `finally` cleanup of staging would delete the only vault copy with no way
// to roll back.
const stateDir = agentStateDir(this.root, projectId, agentId);
const incomingState = path.join(extractDir, "agent_state");
let vault: Buffer | null = null;
try {
vault = await fs.readFile(agentVaultPath(this.root, projectId, agentId));
} catch {
// No vault means nothing to preserve.
}
if (vault !== null) {
await fs.writeFile(path.join(incomingState, VAULT_BASENAME), vault, { mode: 0o600 });
}
const trash = path.join(staging, "replaced-agent_state");
await fs.rename(stateDir, trash);
try {
await fs.rename(incomingState, stateDir);
} catch (err) {
await fs.rename(trash, stateDir); // Rollback: restore the old directory if swapping in the new one fails.
throw err;
}
return { version: incoming };
} finally {
await fs.rm(staging, { recursive: true, force: true });
}
}
}
@@ -0,0 +1,683 @@
/**
* Trace service.
*
* History messages: all of the Session's index files concatenated in order
* (readTraceTolerant, tolerating a truncated last line), containing only the
* complete messages and events that were actually written to Trace (naturally
* excluding partial_*); in-flight increments are continued by SSE.
* Performance analysis is derived from a single Trace file: nearest-neighbor
* pairing of request_begin/end, tool call duration pairing, reconnect / compaction
* counts, and Token trend.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { agentsDir, readTraceTolerant, tracesDir } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type {
AgentTracesResponse,
RequestSpan,
ToolCallSpan,
TraceAnalysisResponse,
TraceEventsResponse,
TraceFileInfo,
TraceModelSegment,
TraceTaskStats,
TraceToolSpan,
UsageTrendPointInTrace,
} from "../api/types.js";
import { HttpError } from "../http/errors.js";
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
/** Recursion depth cap for sub-session expansion (run_subagent depth is already constrained by the SDK; this is just a defensive backstop against cycles). */
const MAX_SUBAGENT_DEPTH = 4;
interface LocatedFile {
path: string;
date: string;
index: number;
}
/**
* A **direct sub-session pointer** (the `subagent` event in the parent Trace) ->
* the sub-session's Session id. The pointer only
* records the Session id; the sub-session's Agent is located within the Project by
* its Trace file.
*/
function subagentPointer(msg: OmniMessage): string | null {
if (msg.type !== "event_msg") return null;
const p = msg.payload as { type?: string; session_id?: unknown };
if (p.type !== "subagent" || typeof p.session_id !== "string" || p.session_id === "") {
return null;
}
return p.session_id;
}
async function listDirs(dir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
async function listFiles(dir: string): Promise<string[]> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
export class TraceService {
constructor(private readonly root: string) {}
/** All of this Session's Trace files (sorted by index ascending). */
private async locateAll(
projectId: string,
agentId: string,
sessionId: string,
): Promise<LocatedFile[]> {
const dir = tracesDir(this.root, projectId, agentId);
const out: LocatedFile[] = [];
for (const dateDir of await listDirs(dir)) {
for (const file of await listFiles(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file);
if (!match || match[1] !== sessionId) continue;
out.push({ path: path.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) });
}
}
return out.sort((a, b) => a.index - b.index);
}
/** Deletes all of this Session's Trace files (called when the Session is deleted). */
async deleteSessionTraces(projectId: string, agentId: string, sessionId: string): Promise<void> {
const files = await this.locateAll(projectId, agentId, sessionId);
for (const file of files) {
await fs.rm(file.path, { force: true });
}
}
/**
* History messages: all index files concatenated in order, with sub-sessions
* **expanded in place**.
*
* The parent Trace only records a `subagent` pointer event at the spawn point
* (recording just the child Session id; the content lives in the child
* Session's own Trace). Here the pointer is used to locate the child Trace
* within the Project, read it recursively, and splice the child messages —
* tagged with an origin chain — back in at the pointer's position, so that when
* the session is reopened, the frontend can re-attach the sub-session to the
* run_subagent tool card via origin (the child Trace's first `session_meta`,
* once given an origin, takes the same shape as what's forwarded over the live
* stream). When expansion succeeds, the pointer event itself is no longer
* emitted; when the child Trace is missing (deleted), the pointer event is kept
* so API consumers can still know it existed.
*/
async readMessages(
projectId: string,
agentId: string,
sessionId: string,
): Promise<OmniMessage[]> {
return this.readMessagesExpanded(projectId, agentId, sessionId, {
index: null,
ancestry: new Set([sessionId]),
depth: 0,
});
}
/**
* A Project-wide session location index (sessionId -> agentId): built by
* scanning every Agent's traces directory. Built lazily the first time a
* subagent pointer is encountered, then reused across the whole readMessages
* call — rescanning per pointer would blow up into tens of thousands of readdir
* calls under multiple sub-sessions plus recursive expansion.
*/
private async buildSessionIndex(projectId: string): Promise<Map<string, string>> {
const index = new Map<string, string>();
for (const agentId of await listDirs(agentsDir(this.root, projectId))) {
const dir = tracesDir(this.root, projectId, agentId);
for (const dateDir of await listDirs(dir)) {
for (const file of await listFiles(path.join(dir, dateDir))) {
const match = TRACE_FILE_RE.exec(file);
if (match && !index.has(match[1]!)) index.set(match[1]!, agentId);
}
}
}
return index;
}
private async readMessagesExpanded(
projectId: string,
agentId: string,
sessionId: string,
ctx: { index: Map<string, string> | null; ancestry: Set<string>; depth: number },
): Promise<OmniMessage[]> {
const files = await this.locateAll(projectId, agentId, sessionId);
const out: OmniMessage[] = [];
for (const file of files) {
for (const msg of await readTraceTolerant(file.path)) {
// The depth cap guards against runaway recursion; ancestry guards against a
// cyclic pointer (a tampered Trace pointing to itself/an ancestor is not expanded).
const childSid = ctx.depth < MAX_SUBAGENT_DEPTH ? subagentPointer(msg) : null;
if (!childSid || ctx.ancestry.has(childSid)) {
out.push(msg);
continue;
}
ctx.index ??= await this.buildSessionIndex(projectId);
const childAgent = ctx.index.get(childSid);
let nested: OmniMessage[] = [];
if (childAgent) {
ctx.ancestry.add(childSid);
nested = await this.readMessagesExpanded(projectId, childAgent, childSid, {
...ctx,
depth: ctx.depth + 1,
});
ctx.ancestry.delete(childSid);
}
// Child Trace missing (deleted): keep the pointer event, since the sub-session's content can't be recovered.
if (nested.length === 0) {
out.push(msg);
continue;
}
for (const m of nested) out.push({ ...m, origin: [childSid, ...(m.origin ?? [])] });
}
}
return out;
}
/** List of Trace files (index / date / size / mtime). */
async listTraceFiles(
projectId: string,
agentId: string,
sessionId: string,
): Promise<TraceFileInfo[]> {
const files = await this.locateAll(projectId, agentId, sessionId);
const out: TraceFileInfo[] = [];
for (const file of files) {
const stat = await fs.stat(file.path);
out.push({
index: file.index,
date: file.date,
sizeBytes: stat.size,
mtime: stat.mtime.toISOString(),
});
}
return out;
}
/** Reads events from the Trace file at the given index, paginated by line (for loading large files in pages). */
async readEvents(
projectId: string,
agentId: string,
sessionId: string,
index: number,
offset: number,
limit: number,
): Promise<TraceEventsResponse> {
const messages = await this.readFileByIndex(projectId, agentId, sessionId, index);
return {
events: messages.slice(offset, offset + limit),
offset,
limit,
total: messages.length,
};
}
/** Performance analysis: derived from a single Trace file. */
async analyze(
projectId: string,
agentId: string,
sessionId: string,
index: number,
): Promise<TraceAnalysisResponse> {
const messages = await this.readFileByIndex(projectId, agentId, sessionId, index);
const requests: RequestSpan[] = [];
let openRequest: RequestSpan | null = null;
const toolCalls: ToolCallSpan[] = [];
const openToolCalls = new Map<string, ToolCallSpan>();
let reconnectCount = 0;
let compactionCount = 0;
const usageTrend: UsageTrendPointInTrace[] = [];
// Timeline (serial-duration estimation): Trace records completion times; model
// messages are produced
// serially (autoregressive decoding), so each segment's start = the previous
// event's time (the request's first segment = request_begin); a tool's
// approval/execution runs in parallel with model decoding, on its own lane;
// prevSerialTs is cleared after request_end, and the next request_begin
// restarts the count (which presumes all of the previous round's
// tool_call_output have already come back).
const modelSegments: TraceModelSegment[] = [];
const toolSpans: TraceToolSpan[] = [];
const openSpansById = new Map<string, TraceToolSpan>();
let prevSerialTs: string | null = null;
// Task grouping: one user turn contains multiple Request rounds (the Agent
// loop sends another round each time it calls a tool); the turn ends once the
// model produces only text with no further tool call. Consecutive Requests are
// merged into one Task on this basis, and each Task gets its own independent
// timeline — different Tasks can be far apart in time (the user is thinking or
// has stepped away), and sharing one timeline would leave large gaps.
// Compaction forms its own turn: both compaction_begin/compaction_end break a
// continuation, so the compaction request becomes its own Task, and the
// request that resumes after compaction starts yet another Task.
let taskIndex = -1;
let continuation = false; // The previous round's Request called a tool -> the next request_begin continues the same Task
let sawToolCallThisRequest = false;
// Compaction interval (compaction_begin..compaction_end): the compaction
// request's request_begin/request_end and token_usage all fall inside it (see
// core context-engine's summarize flow), which is used to exclude the
// compaction request entirely from TPS — matching the same convention as
// compactionActive in the Chat page's task-stats.
let compactionActive = false;
// Token / duration totals per Task (computed server-side over the whole file:
// frontend events are fetched in pages, so summing them there would be
// mismatched).
const taskStats = new Map<number, TraceTaskStats>();
const ensureTask = (ti: number): TraceTaskStats => {
let t = taskStats.get(ti);
if (t === undefined) {
t = {
taskIndex: ti,
messageFrom: -1,
messageTo: -1,
startTs: "",
endTs: "",
tokens: { cacheRead: 0, cacheWrite: 0, output: 0 },
llmMs: 0,
};
taskStats.set(ti, t);
}
return t;
};
/**
* Which turn each message belongs to: **decided definitively in one
* sequential pass**, not left for the frontend to guess by timestamp.
*
* Timestamp boundaries can't be pulled apart — the same millisecond can
* contain "the previous turn's last reply, compaction_begin, the compaction
* prompt, and the next turn's request_begin" all at once, so assigning by
* time would inevitably misfile this turn's reply into the next turn.
*
* Rule (a turn = one user turn; `request_end` is the end of some Request
* within a turn):
* - The **starting marker** of a new turn: the main session's user Prompt
* (outside compaction), or compaction_begin (compaction forms its own
* turn). Messages after the marker and before that turn's first
* `request_begin` (subsequent images from a multi-image send, the
* compaction prompt) are always held pending, waiting for
* `request_begin` to settle the new taskIndex before the whole span is
* assigned at once — they belong to the **new** turn, not the tail of
* the previous one.
* - Other messages belong to the current taskIndex: tool output and
* approval decisions arriving after request_end still belong to this
* turn (they're the results of tools this turn's Request initiated).
*/
const msgTask: number[] = new Array<number>(messages.length).fill(-1);
/** The pending new turn's starting point (message index); settled once request_begin determines the taskIndex. */
let pendingFrom: number | null = null;
for (let mi = 0; mi < messages.length; mi++) {
const msg = messages[mi]!;
const p = msg.payload as Record<string, unknown> & { type?: string };
// The timeline only looks at the main session (a Trace itself never contains origin messages; this is a defensive skip).
const hasOrigin = msg.origin !== undefined && msg.origin.length > 0;
// Starting marker of a new turn: the main session's user Prompt (outside
// compaction) -> a new user turn; compaction_begin -> a compaction turn
// (compaction forms its own turn). A single send can be "text + multiple
// images" = multiple messages; only the **first** one counts (once
// pendingFrom is set, it's not changed again), otherwise the turn's start
// would shift to the last image.
const startsUserTurn =
!hasOrigin &&
!compactionActive &&
msg.type === "model_msg" &&
((p.type === "text" && p.role === "user") || p.type === "image_url");
const startsCompactionTurn =
!hasOrigin && msg.type === "event_msg" && p.type === "compaction_begin";
if (startsUserTurn || startsCompactionTurn) {
if (pendingFrom === null) pendingFrom = mi;
// A user Prompt **always starts a new turn**: judging continuation solely
// by "did the previous turn call a tool" isn't enough — if the previous
// turn ended in timeout/malformed (given up after exhausting retries),
// retryable would leave continuation at true, and this new message would
// get merged into that failed turn, smearing the two turns' messages /
// Tokens / TPS / duration together.
if (startsUserTurn) continuation = false;
}
// A main-session message that isn't pending belongs to the current turn immediately (taskIndex < 0 = before the first request_begin, e.g. session_meta).
if (!hasOrigin && pendingFrom === null) msgTask[mi] = taskIndex;
if (msg.type === "event_msg") {
if (p.type === "request_begin") {
if (!hasOrigin) {
prevSerialTs = msg.timestamp;
if (!continuation) taskIndex++; // Not a continuation -> a new Task
sawToolCallThisRequest = false;
}
// Settle taskIndex before opening the span: the span belongs directly to
// the current Task. Nearest-neighbor pairing: if the previous begin was
// never closed (process exited mid-run), the span is left open.
openRequest = { beginTs: msg.timestamp, taskIndex };
if (compactionActive) openRequest.compaction = true;
requests.push(openRequest);
if (!hasOrigin) {
const t = ensureTask(taskIndex);
if (compactionActive) t.compaction = true; // This turn is a compaction turn
// This turn's duration starts at the first request_begin. It doesn't
// use the timestamp of the user Prompt / compaction summary or other
// user text: `<context_summary>` is created during compaction but only
// written to disk on the next run, so resuming the next day would
// stretch the first turn out by a whole day for no reason; the Prompt
// to request-dispatch gap is only ever milliseconds anyway.
if (t.startTs === "") t.startTs = msg.timestamp;
// The new turn's taskIndex is only settled here: the pending span
// (user Prompt / multiple images / compaction prompt) is assigned in
// full to **this** turn — they're the start of the new turn, not the
// tail of the previous one.
if (pendingFrom !== null) {
for (let k = pendingFrom; k < mi; k++) {
if (messages[k]!.origin === undefined) msgTask[k] = taskIndex;
}
pendingFrom = null;
}
msgTask[mi] = taskIndex;
}
} else if (p.type === "approval_decision") {
if (!hasOrigin && typeof p.tool_call_id === "string") {
const span = openSpansById.get(p.tool_call_id);
if (span && span.approvalTs === undefined) {
span.approvalTs = msg.timestamp;
if (typeof p.decision === "string") span.decision = p.decision;
// Approval wait time is subtracted out of the LLM generation
// duration: core does `await approve(tc)` inside the streaming loop,
// so the entire manual wait sits between request_begin and
// request_end (see RequestSpan.approvalWaitMs). Without subtracting
// it, "5s generation + 55s approval wait" would show 100 tok/s as 8 tok/s.
if (openRequest) {
const wait = Date.parse(msg.timestamp) - Date.parse(span.callTs);
if (Number.isFinite(wait) && wait > 0) {
openRequest.approvalWaitMs = (openRequest.approvalWaitMs ?? 0) + wait;
}
}
}
}
} else if (p.type === "request_end") {
const status = typeof p.status === "string" ? p.status : undefined;
// timeout/malformed is automatically reconnected by core within the same
// run (context-engine's retry loop); the resent Request still belongs to
// **the same user turn**: it must continue the turn, otherwise a single
// timeout would split that turn's Tokens/duration/TPS across two Tasks.
const retryable = status === "timeout" || status === "malformed";
if (!hasOrigin) {
prevSerialTs = null;
continuation = sawToolCallThisRequest || retryable;
}
if (retryable) reconnectCount++;
if (openRequest) {
openRequest.endTs = msg.timestamp;
const dur = Date.parse(msg.timestamp) - Date.parse(openRequest.beginTs);
if (Number.isFinite(dur)) {
openRequest.durationMs = dur;
openRequest.activeMs = Math.max(0, dur - (openRequest.approvalWaitMs ?? 0));
}
if (status !== undefined) openRequest.status = status;
// TPS denominator: accumulated per the turn a Request belongs to. A
// compaction request counts too — it belongs to **its own compaction
// turn** (compaction forms its own turn), so it neither pollutes a
// user turn's TPS, nor does the compaction turn fail to report its own
// generation speed accurately. A failed retry's duration is counted as
// well — it belongs to the same turn as the retry that eventually
// succeeded, and "how long this turn took to produce these tokens"
// should include the retries by definition.
if (!hasOrigin && openRequest.activeMs !== undefined) {
ensureTask(openRequest.taskIndex).llmMs += openRequest.activeMs;
}
openRequest = null;
}
} else if (p.type === "compaction_begin") {
compactionCount++;
// Compaction forms its own turn: otherwise, if the previous turn called
// a tool, continuation would still be true and the compaction request
// would get merged into the previous Task.
if (!hasOrigin) {
continuation = false;
compactionActive = true;
}
} else if (p.type === "compaction_end") {
// Both ends of compaction break a continuation. This closing one can't
// be skipped: if the compaction request itself exhausts its retries and
// ends in timeout, the retryable check above would mark it as "continued",
// and without clearing it here, the next user turn after compaction
// would get merged into this compaction Task.
if (!hasOrigin) {
continuation = false;
compactionActive = false;
}
} else if (p.type === "token_usage") {
const request = p.request as
| { total?: number; cache_read?: number; cache_write?: number; output?: number }
| undefined;
const session = p.session as { total?: number } | undefined;
usageTrend.push({
ts: msg.timestamp,
requestTotal: request?.total ?? 0,
sessionTotal: session?.total ?? 0,
});
if (!hasOrigin) {
const t = ensureTask(taskIndex);
// Cumulative usage for this turn (a running total): those tokens were
// actually paid for, so the cost can't be dropped. `tokens.output` also
// doubles as the numerator for output TPS — compaction's output
// belongs to **its own compaction turn** (compaction forms its own
// turn), so a user turn's TPS isn't polluted by it, while the
// compaction turn can still accurately report "how fast the summary
// was generated".
t.tokens.cacheRead += request?.cache_read ?? 0;
t.tokens.cacheWrite += request?.cache_write ?? 0;
t.tokens.output += request?.output ?? 0;
if (!compactionActive) {
// The context snapshot only takes non-compaction Requests: tokens
// consumed by compaction aren't the post-compaction context
// footprint. A later write overwrites an earlier one -> this
// naturally leaves behind the snapshot of the Task's **last**
// non-compaction Request = the context footprint at the end of this
// turn. Accumulating would be wrong: each Request's input carries
// the entire history afresh (see TraceTaskStats).
t.context = {
cacheRead: request?.cache_read ?? 0,
cacheWrite: request?.cache_write ?? 0,
output: request?.output ?? 0,
};
}
}
}
continue;
}
if (msg.type !== "model_msg") continue;
// Model serial segments: assistant-side thinking/text/tool_call (a user input sent instantaneously occupies no segment).
if (
!hasOrigin &&
prevSerialTs !== null &&
(p.type === "thinking" ||
p.type === "tool_call" ||
(p.type === "text" && p.role === "assistant"))
) {
const segment: TraceModelSegment = {
kind: p.type === "thinking" ? "thinking" : p.type === "tool_call" ? "tool_call" : "text",
startTs: prevSerialTs,
endTs: msg.timestamp,
taskIndex,
};
if (p.type === "tool_call" && typeof p.tool_call_id === "string") {
segment.toolCallId = p.tool_call_id;
if (typeof p.name === "string") segment.name = p.name;
}
modelSegments.push(segment);
prevSerialTs = msg.timestamp;
}
if (p.type === "tool_call" && typeof p.tool_call_id === "string") {
if (!hasOrigin) sawToolCallThisRequest = true; // This turn called a tool -> the next turn continues the same Task
const callStop = typeof p.stop_reason === "string" ? p.stop_reason : undefined;
const span: ToolCallSpan = {
toolCallId: p.tool_call_id,
name: typeof p.name === "string" ? p.name : "",
startTs: msg.timestamp,
};
if (callStop !== undefined) span.stopReason = callStop;
openToolCalls.set(p.tool_call_id, span);
toolCalls.push(span);
// The timeline lane only accepts calls that "will actually be executed":
// an interrupt-compensation tool_call (stop_reason other than completed)
// never gets an approval/output, and putting it on a lane would render as
// a phantom "executing" state spanning the whole timeline — so it's
// skipped outright.
if (!hasOrigin && (callStop === undefined || callStop === "completed")) {
const timeline: TraceToolSpan = {
toolCallId: p.tool_call_id,
name: typeof p.name === "string" ? p.name : "",
callTs: msg.timestamp,
taskIndex,
};
openSpansById.set(p.tool_call_id, timeline);
toolSpans.push(timeline);
}
} else if (p.type === "tool_call_output" && typeof p.tool_call_id === "string") {
const span = openToolCalls.get(p.tool_call_id);
if (span && span.endTs === undefined) {
span.endTs = msg.timestamp;
const dur = Date.parse(msg.timestamp) - Date.parse(span.startTs);
if (Number.isFinite(dur)) span.durationMs = dur;
if (typeof p.stop_reason === "string") span.stopReason = p.stop_reason;
}
const timeline = openSpansById.get(p.tool_call_id);
if (timeline && timeline.outputTs === undefined) {
timeline.outputTs = msg.timestamp;
if (typeof p.stop_reason === "string") timeline.stopReason = p.stop_reason;
}
}
}
// A pending span that never got a request_begin (interrupted right after the
// user sent it / the process exited): it's a turn that never got to run,
// and forms its own turn — reattaching it to the previous turn would smear
// two separate user sends together.
if (pendingFrom !== null) {
taskIndex++;
for (let k = pendingFrom; k < messages.length; k++) {
if (messages[k]!.origin === undefined) msgTask[k] = taskIndex;
}
ensureTask(taskIndex);
}
// Each turn's message index range and end-of-turn time are always derived from
// the per-message assignment done above (same source, so they never disagree
// with each other). Messages before the first request_begin (session_meta)
// have taskIndex -1 and are assigned to the first turn, otherwise they'd have
// nowhere to sit on the page. The turn duration's **starting point** isn't
// decided here — it was already settled at request_begin (duration only looks
// at LLM requests; timestamps of the user Prompt / compaction summary or other
// user text don't participate, see TraceTaskStats.startTs).
const firstTask = [...taskStats.keys()].sort((a, b) => a - b)[0];
for (let k = 0; k < messages.length; k++) {
let ti = msgTask[k]!;
if (ti < 0) {
if (firstTask === undefined) continue;
ti = firstTask;
msgTask[k] = ti;
}
const t = ensureTask(ti);
if (t.messageFrom < 0 || k < t.messageFrom) t.messageFrom = k;
if (k > t.messageTo) t.messageTo = k;
// session_meta is only **listed** in the first turn, and doesn't count
// toward the end-of-turn time: it's metadata written when the session was
// created, and its timestamp has nothing to do with this turn (it also gets
// rewritten verbatim at the start of a new file after compaction splits the file).
if (messages[k]!.type === "session_meta") continue;
const ts = messages[k]!.timestamp;
if (t.endTs === "" || ts > t.endTs) t.endTs = ts;
}
const tasks = [...taskStats.values()].sort((a, b) => a.taskIndex - b.taskIndex);
// Total elapsed time = **the sum of each turn's duration**, matching exactly
// the scope shown per-turn below (**including compaction turns** — their wall
// clock time genuinely elapsed, each turn's card has its own duration, and the
// overall total is their sum, so the numbers must add up). It is not "last
// message timestamp minus first message timestamp": that would be the whole
// file's wall-clock span, counting in the gaps **between** turns (the user
// thinking, stepping out for coffee, coming back the next day) — none of which
// is time the Agent spent working. A degenerate turn with no Request has an
// empty startTs and counts as 0.
// Note this uses a different convention from the Session's cumulative elapsed
// time on the Chat page: that one only accumulates user turns (compaction
// after a turn ends doesn't count toward the turn).
const elapsedMs = tasks.reduce((sum, t) => {
const span = Date.parse(t.endTs) - Date.parse(t.startTs);
return sum + (Number.isFinite(span) ? Math.max(0, span) : 0);
}, 0);
return {
elapsedMs,
requests,
tasks,
toolCalls,
modelSegments,
toolSpans,
reconnectCount,
compactionCount,
usageTrend,
};
}
/** Level-by-level browsing (newest first): Agent -> date -> Session -> Trace files. */
async agentTraces(projectId: string, agentId: string): Promise<AgentTracesResponse> {
const dir = tracesDir(this.root, projectId, agentId);
const dates = (await listDirs(dir)).sort().reverse();
const out: AgentTracesResponse = { dates: [] };
for (const date of dates) {
const bySession = new Map<string, { index: number; sizeBytes: number }[]>();
for (const file of await listFiles(path.join(dir, date))) {
const match = TRACE_FILE_RE.exec(file);
if (!match) continue;
const sessionId = match[1]!;
const stat = await fs.stat(path.join(dir, date, file));
const files = bySession.get(sessionId) ?? [];
files.push({ index: Number(match[2]), sizeBytes: stat.size });
bySession.set(sessionId, files);
}
if (bySession.size === 0) continue;
out.dates.push({
date,
// session_id embeds a timestamp, so reverse lexicographic order is reverse chronological order.
sessions: [...bySession.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([sessionId, files]) => ({
sessionId,
files: files.sort((a, b) => a.index - b.index),
})),
});
}
return out;
}
private async readFileByIndex(
projectId: string,
agentId: string,
sessionId: string,
index: number,
): Promise<OmniMessage[]> {
const files = await this.locateAll(projectId, agentId, sessionId);
const file = files.find((f) => f.index === index);
if (!file) {
throw new HttpError(
404,
"trace_not_found",
`该 Session 没有 index 为 ${index} 的 Trace 文件。`,
);
}
return readTraceTolerant(file.path);
}
}
@@ -0,0 +1,269 @@
/**
* Usage statistics query.
*
* Cost is **computed in real time**: usage_records only stores Tokens (pricing may
* be added later), so at query time each Model's cost is converted using the
* current Project's configured pricing — the repo returns raw Token totals broken
* down by `(provider, model_id)` paired reference, and this service looks up each
* reference's price once and folds it into cost / hasUncosted (if a Model has no
* pricing, its consumption is excluded from cost and hasUncosted is flagged).
* Summary cards (today / last 7 days / cumulative), grouped aggregation (date /
* agent / model / session, with the session dimension supporting agentId drill-down
* filtering), and a 30-day trend.
* Server-side error statistics (error_records) ride along on the same response:
* the statistics center fetches everything in one request, and filters are
* naturally shared; unattributed errors (login failures, process crashes, and other
* errors with no Project context) are visible only to admins, see the ErrorsRepo
* file header.
*/
import type {
UsageBucket,
UsageErrors,
UsageGroupBy,
UsageGroupRow,
UsageResponse,
} from "../api/types.js";
import type { ErrorFilter, ErrorsRepo } from "../db/repos/errors.js";
import type {
UsageRepo,
UsageModelSums,
UsageGroupModelSums,
UsageFilter,
} from "../db/repos/usage.js";
import { formatLocalDate, localDateMinusDays } from "../internal/dates.js";
/** Number of most-recent entries kept in the error detail table. */
const ERROR_RECENT_N = 20;
/** The three pricing buckets (usd_per_mtok convention), returned by the pricing lookup callback. */
export interface PricingRates {
cacheRead: number;
cacheWrite: number;
output: number;
}
export type PricingLookup = (
projectId: string,
provider: string,
modelId: string,
) => Promise<PricingRates | undefined>;
export interface UsageQuery {
from?: string;
to?: string;
groupBy: UsageGroupBy;
/** Top-level filter: view by Agent (also used for groupBy=session drill-down). */
agentId?: string;
/** Top-level filter: view by Model (paired with modelId; the dropdown always sends them as a pair). */
provider?: string;
modelId?: string;
/** Whether to include unattributed errors: admin only (the route passes user.isAdmin), defaults to false. */
includeGlobalErrors?: boolean;
}
/** Cost formula: sum of the three buckets, in USD per million Tokens. */
function costOf(sums: UsageModelSums, rates: PricingRates): number {
return (
(sums.cacheRead * rates.cacheRead +
sums.cacheWrite * rates.cacheWrite +
sums.output * rates.output) /
1e6
);
}
/** In-process Map key for a paired reference (\0-separated, the same style as session-manager's agentKey; never persisted). */
function refKey(provider: string, modelId: string): string {
return `${provider}\0${modelId}`;
}
export class UsageService {
constructor(
private readonly usage: UsageRepo,
private readonly errors: ErrorsRepo,
private readonly lookupPricing: PricingLookup,
private readonly now: () => Date = () => new Date(),
) {}
async query(projectId: string, q: UsageQuery): Promise<UsageResponse> {
const today = formatLocalDate(this.now());
// Top-level filter: agent + model (the cost center switches views by agent/model; the model filter is always sent as a pair).
const base: UsageFilter = {};
if (q.agentId !== undefined) base.agentId = q.agentId;
if (q.provider !== undefined) base.provider = q.provider;
if (q.modelId !== undefined) base.modelId = q.modelId;
const win = (from?: string, to?: string): UsageFilter => ({
...base,
...(from !== undefined ? { from } : {}),
...(to !== undefined ? { to } : {}),
});
const todayRows = this.usage.bucketByModel(projectId, win(today, today));
const last7dRows = this.usage.bucketByModel(projectId, win(localDateMinusDays(this.now(), 6)));
const totalRows = this.usage.bucketByModel(projectId, win(q.from, q.to));
const groupRows = this.usage.groupsByModel(projectId, q.groupBy, win(q.from, q.to));
// Fixed 30-day window; affected by the agent/model filter.
const trendFrom = localDateMinusDays(this.now(), 29);
const trendRows = this.usage.groupsByModel(projectId, "date", win(trendFrom));
// Agent call-count chart: not affected by the agent filter (shows all agents), but still affected by the date + model filter.
const agentRows = this.usage.groupsByModel(projectId, "agent", {
...(q.provider !== undefined ? { provider: q.provider } : {}),
...(q.modelId !== undefined ? { modelId: q.modelId } : {}),
...(q.from !== undefined ? { from: q.from } : {}),
...(q.to !== undefined ? { to: q.to } : {}),
});
// Model success-rate chart: not affected by the model filter (shows all models), but still affected by the date + agent filter.
const statusRows = this.usage.statusByModel(projectId, {
...(q.agentId !== undefined ? { agentId: q.agentId } : {}),
...(q.from !== undefined ? { from: q.from } : {}),
...(q.to !== undefined ? { to: q.to } : {}),
});
// Error statistics: likewise not affected by the model filter (HTTP / process errors have no Model dimension), but still affected by the date + agent filter.
const errorFilter: ErrorFilter = {
...(q.agentId !== undefined ? { agentId: q.agentId } : {}),
...(q.from !== undefined ? { from: q.from } : {}),
...(q.to !== undefined ? { to: q.to } : {}),
// Unattributed errors are visible only to admins (regular members only see errors within their own Project, see the ErrorsRepo file header).
...(q.includeGlobalErrors === true ? { includeGlobal: true } : {}),
};
// Each paired reference that occurs is looked up for its current price only once.
const rates = new Map<string, PricingRates | undefined>();
const allRefs = new Map<string, { provider: string; modelId: string }>();
for (const r of [...todayRows, ...last7dRows, ...totalRows, ...groupRows, ...trendRows]) {
allRefs.set(refKey(r.provider, r.modelId), { provider: r.provider, modelId: r.modelId });
}
for (const [key, ref] of allRefs) {
rates.set(key, await this.lookupPricing(projectId, ref.provider, ref.modelId));
}
const byAgentMap = new Map<string, { requests: number; total: number }>();
for (const r of agentRows) {
const acc = byAgentMap.get(r.key) ?? { requests: 0, total: 0 };
acc.requests += r.requests;
acc.total += r.total;
byAgentMap.set(r.key, acc);
}
return {
summary: {
today: this.foldBucket(todayRows, rates),
last7d: this.foldBucket(last7dRows, rates),
total: this.foldBucket(totalRows, rates),
},
groupBy: q.groupBy,
groups: this.foldGroups(groupRows, rates, q.groupBy),
trend: this.foldTrend(trendRows, rates),
byAgent: [...byAgentMap.entries()]
.map(([agentId, v]) => ({ agentId, requests: v.requests, total: v.total }))
.sort((a, b) => b.requests - a.requests),
success: statusRows.sort((a, b) => b.total - a.total),
errors: this.foldErrors(projectId, errorFilter),
agentIds: this.usage.distinctAgentIds(projectId),
models: this.usage.distinctModels(projectId),
};
}
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
private foldErrors(projectId: string, f: ErrorFilter): UsageErrors {
const { total, unexpected } = this.errors.summary(projectId, f);
return {
total,
unexpected,
topCode: this.errors.topCode(projectId, f),
recent: this.errors.recent(projectId, f, ERROR_RECENT_N),
};
}
private foldBucket(
rows: UsageModelSums[],
rates: Map<string, PricingRates | undefined>,
): UsageBucket {
let total = 0;
let requests = 0;
let cost: number | null = null;
let hasUncosted = false;
for (const r of rows) {
total += r.total;
requests += r.requests;
const rate = rates.get(refKey(r.provider, r.modelId));
if (rate) cost = (cost ?? 0) + costOf(r, rate);
else hasUncosted = true;
}
return { total, requests, cost, hasUncosted };
}
private foldGroups(
rows: UsageGroupModelSums[],
rates: Map<string, PricingRates | undefined>,
groupBy: UsageGroupBy,
): UsageGroupRow[] {
// The model dimension folds by paired reference (a shared model_id name across providers is split into separate rows); other dimensions fold by their group key.
const keyOf = (r: UsageGroupModelSums): string =>
groupBy === "model" ? refKey(r.provider, r.key) : r.key;
const byKey = new Map<string, UsageGroupRow>();
for (const r of rows) {
const acc = byKey.get(keyOf(r)) ?? {
key: r.key,
...(groupBy === "model" ? { provider: r.provider } : {}),
cacheRead: 0,
cacheWrite: 0,
output: 0,
total: 0,
requests: 0,
cost: null as number | null,
hasUncosted: false,
};
acc.cacheRead += r.cacheRead;
acc.cacheWrite += r.cacheWrite;
acc.output += r.output;
acc.total += r.total;
acc.requests += r.requests;
const rate = rates.get(refKey(r.provider, r.modelId));
if (rate) acc.cost = (acc.cost ?? 0) + costOf(r, rate);
else acc.hasUncosted = true;
byKey.set(keyOf(r), acc);
}
const out = [...byKey.values()];
// The date dimension sorts by key descending (most recent first); other dimensions sort by total Token count descending.
if (groupBy === "date") out.sort((a, b) => b.key.localeCompare(a.key));
else out.sort((a, b) => b.total - a.total);
return out;
}
private foldTrend(
rows: UsageGroupModelSums[],
rates: Map<string, PricingRates | undefined>,
): UsageResponse["trend"] {
const byDate = new Map<
string,
{ total: number; cacheRead: number; cacheWrite: number; output: number; cost: number | null }
>();
for (const r of rows) {
const acc = byDate.get(r.key) ?? {
total: 0,
cacheRead: 0,
cacheWrite: 0,
output: 0,
cost: null,
};
acc.total += r.total;
acc.cacheRead += r.cacheRead;
acc.cacheWrite += r.cacheWrite;
acc.output += r.output;
const rate = rates.get(refKey(r.provider, r.modelId));
if (rate) acc.cost = (acc.cost ?? 0) + costOf(r, rate);
byDate.set(r.key, acc);
}
return [...byDate.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, v]) => ({
date,
total: v.total,
cacheRead: v.cacheRead,
cacheWrite: v.cacheWrite,
output: v.output,
cost: v.cost,
}));
}
}
@@ -0,0 +1,283 @@
/**
* Workspace file browsing: list directory / read
* file (preview & download) / write file (upload). Security: a relative path, once
* resolved, must stay inside the Workspace — a logical prefix check plus a realpath
* check against the nearest existing ancestor (guards against `..` and symlink escapes).
*/
import fs from "node:fs/promises";
import { constants as fsc } from "node:fs";
import path from "node:path";
import type { WorkspaceFilesResponse } from "../api/types.js";
import { HttpError } from "../http/errors.js";
import { badRequest } from "../http/validate.js";
/** Per-file read cap (a safety limit since preview/download reads the whole file into memory). */
const MAX_READ_BYTES = 50 * 1024 * 1024;
/** Upload cap (stays within the 20MB request body limit even after base64 encoding). */
export const MAX_UPLOAD_BYTES = 14 * 1024 * 1024;
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".json": "application/json",
".js": "text/javascript; charset=utf-8",
".ts": "text/plain; charset=utf-8",
".tsx": "text/plain; charset=utf-8",
".py": "text/plain; charset=utf-8",
".sh": "text/plain; charset=utf-8",
".yaml": "text/plain; charset=utf-8",
".yml": "text/plain; charset=utf-8",
".toml": "text/plain; charset=utf-8",
".css": "text/css; charset=utf-8",
".csv": "text/plain; charset=utf-8",
".log": "text/plain; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".pdf": "application/pdf",
};
export interface WorkspaceFileContent {
data: Buffer;
fileName: string;
contentType: string;
/** Types whose same-origin inline rendering would execute scripts (html/svg): inline preview must fall back to plain text. */
scriptable: boolean;
}
export class WorkspaceFilesService {
/** Canonical path (realpath) of the Workspace root; 404 if it doesn't exist. */
private async realBase(workspace: string): Promise<string> {
try {
return await fs.realpath(path.resolve(workspace));
} catch {
throw new HttpError(404, "workspace_missing", "该 Session 的 Workspace 已不存在。");
}
}
/**
* Lexical containment check: whether target is inside base (including equal to
* base). Uses path.relative rather than prefix concatenation, so it works when
* base is the filesystem root ("/" concatenated with sep would produce a "//"
* prefix that no subpath could ever match); only a full ".." segment is
* compared, so a legitimate name like "..foo" isn't mistakenly rejected.
*/
private isInside(target: string, base: string): boolean {
const rel = path.relative(base, target);
return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
}
/** 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 非法。");
const target = path.resolve(base, rel === "" ? "." : rel);
if (!this.isInside(target, base)) {
throw badRequest("path 必须位于 Workspace 内。");
}
return target;
}
private assertInside(real: string, realBase: string): void {
if (!this.isInside(real, realBase)) {
throw badRequest("path 必须位于 Workspace 内。");
}
}
/**
* Read-path resolution: realpath the entire path (following all symlinks to get
* a link-free canonical path), then check containment and **perform IO on the
* canonical path** — since the canonical path contains no symlink segments at
* all, this eliminates check-then-use TOCTOU escapes (an out-of-bounds symlink
* is already resolved and rejected at the realpath step).
*/
private async resolveRead(workspace: string, rel: string): Promise<string> {
const realBase = await this.realBase(workspace);
const target = this.lexicalTarget(path.resolve(workspace), rel);
let canonical: string;
try {
canonical = await fs.realpath(target);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "文件不存在。");
}
throw err;
}
this.assertInside(canonical, realBase);
return canonical;
}
/**
* Write-path resolution: realpaths the parent directory (whose canonical path
* has no symlink segments) and checks containment, then appends the final
* segment as the file name. When the parent directory is missing, it is safely
* created (uploading a folder needs to preserve directory structure): first the
* nearest **existing** ancestor is found and its canonical path checked against
* the Workspace — this exposes it if a middle segment was preset as a symlink
* pointing outside; the missing segments are then created recursively beneath it
* (a brand-new directory can never be a symlink), followed by a second realpath
* check after creation. The actual write opens with O_NOFOLLOW (refusing to
* follow a symlink at the final segment), blocking the sandbox-escape pattern of
* "Agent presets a symlink -> an upload is used as leverage to overwrite a file
* outside the sandbox". Returns the canonical parent directory + file name.
*/
private async resolveWriteParent(
workspace: string,
rel: string,
): Promise<{ dir: string; name: string }> {
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 必须是文件路径。");
const parent = path.dirname(target);
let canonicalParent: string;
try {
canonicalParent = await fs.realpath(parent);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
let probe = parent;
while (true) {
try {
this.assertInside(await fs.realpath(probe), realBase);
break;
} catch (probeErr) {
if ((probeErr as NodeJS.ErrnoException).code !== "ENOENT") throw probeErr;
const up = path.dirname(probe);
if (up === probe) throw badRequest("path 非法。");
probe = up;
}
}
await fs.mkdir(parent, { recursive: true });
canonicalParent = await fs.realpath(parent);
}
this.assertInside(canonicalParent, realBase);
return { dir: canonicalParent, name };
}
/**
* Batch existence check (a message's file card lists only files that actually
* exist): each item goes through the same containment resolution as reading
* (resolveRead); out-of-bounds, resolution failure, missing Workspace, or an
* irregular file are all treated as non-existent — the card scenario only asks
* "can this be opened", and throwing a 4xx would only add frontend branches while
* leaking containment details. Returns the deduplicated existing items in input order.
*/
async statExisting(workspace: string, rels: string[]): Promise<string[]> {
const unique = [...new Set(rels)];
const exists = await Promise.all(
unique.map(async (rel) => {
try {
const stat = await fs.stat(await this.resolveRead(workspace, rel));
return stat.isFile();
} catch {
return false;
}
}),
);
return unique.filter((_, i) => exists[i]);
}
/** List a directory: dirs come first, each group sorted by name; kind follows the symlink target (consistent with read behavior). */
async list(workspace: string, rel: string): Promise<WorkspaceFilesResponse> {
const dir = await this.resolveRead(workspace, rel);
let dirents;
try {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new HttpError(404, "path_not_found", "目录不存在。");
}
if ((err as NodeJS.ErrnoException).code === "ENOTDIR") {
throw badRequest("path 不是目录。");
}
throw err;
}
const entries = await Promise.all(
dirents.map(async (d) => {
let sizeBytes = 0;
let mtime = "";
// Dirent doesn't report the target type for a symlink, so stat (following the link) is used to determine dir/file.
let isDir = d.isDirectory();
try {
const stat = await fs.stat(path.join(dir, d.name));
sizeBytes = stat.size;
mtime = stat.mtime.toISOString();
isDir = stat.isDirectory();
} catch {
// A dangling symlink or similar: keep the entry, with size/time left at defaults.
}
return {
name: d.name,
kind: isDir ? ("dir" as const) : ("file" as const),
sizeBytes,
mtime,
};
}),
);
entries.sort((a, b) =>
a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1,
);
return { path: rel, entries };
}
/** Read a file (preview/download): IO on the canonical path (resolveRead has already eliminated symlink escapes). */
async read(workspace: string, rel: string): Promise<WorkspaceFileContent> {
const file = await this.resolveRead(workspace, rel);
let stat;
try {
stat = await fs.stat(file);
} catch {
throw new HttpError(404, "path_not_found", "文件不存在。");
}
if (stat.isDirectory()) throw badRequest("path 是目录。");
if (stat.size > MAX_READ_BYTES) {
throw new HttpError(413, "file_too_large", "文件超过 50MB 读取上限。");
}
const data = await fs.readFile(file);
const ext = path.extname(file).toLowerCase();
return {
data,
fileName: path.basename(file),
contentType: CONTENT_TYPES[ext] ?? "application/octet-stream",
scriptable: ext === ".html" || ext === ".htm" || ext === ".svg",
};
}
/**
* Write a file (upload, overwriting a same-named one). If the parent directory
* is missing, it's automatically created under sandbox checks (preserving
* directory structure for folder uploads); the final segment is opened with
* O_NOFOLLOW, refusing to follow a symlink to write outside the Workspace
* (together with resolveWriteParent's canonical-parent check, this blocks
* sandbox escapes).
*/
async write(workspace: string, rel: string, data: Buffer): Promise<void> {
if (rel === "" || rel.endsWith("/")) throw badRequest("path 必须是文件路径。");
if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。");
}
const { dir, name } = await this.resolveWriteParent(workspace, rel);
const file = path.join(dir, name);
// O_NOFOLLOW: open reports ELOOP if the final segment is a symlink, refusing to use it as leverage to overwrite a file outside the sandbox.
const flags = fsc.O_WRONLY | fsc.O_CREAT | fsc.O_TRUNC | (fsc.O_NOFOLLOW ?? 0);
let handle;
try {
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 是目录。");
throw err;
}
try {
await handle.writeFile(data);
} finally {
await handle.close();
}
}
}
@@ -0,0 +1,37 @@
/**
* Workspace validation.
*
* When a user explicitly specifies a Workspace: after realpath normalization
* (resolving `..` and symlinks), it's required to be an **existing directory**
* (never auto-created). The directory location is not constrained to the
* Project directory — a Workspace can be any path on the server, with actual
* reachability governed by the file permissions of the OS account running the
* service.
* When no Workspace is specified, this module isn't involved (the SDK creates its
* own temporary directory).
*/
import fs from "node:fs/promises";
import { HttpError } from "../http/errors.js";
/**
* Validates and returns the normalized (realpath) Workspace path.
*
* @throws 400 workspace_not_found: the path doesn't exist, isn't readable, or isn't a directory.
*/
export async function assertWorkspaceAllowed(args: { workspace: string }): Promise<string> {
let ws: string;
try {
ws = await fs.realpath(args.workspace);
} catch {
throw new HttpError(
400,
"workspace_not_found",
`Workspace 不存在或不可访问:${args.workspace}。请指定一个已存在的目录,或留空以使用临时目录。`,
);
}
const stat = await fs.stat(ws);
if (!stat.isDirectory()) {
throw new HttpError(400, "workspace_not_found", `Workspace 不是目录:${args.workspace}。`);
}
return ws;
}