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
+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
);
`;