Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
Reference in New Issue
Block a user