Add sssf skill, installable via the skills CLI
Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
This commit is contained in:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
418
sssf/apps/visualizer/server/db.ts
Normal file
418
sssf/apps/visualizer/server/db.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/**
|
||||
* SQLite reader over a target repo's sssf.db.
|
||||
*
|
||||
* The read connection is opened readonly and every query on it is a SELECT —
|
||||
* the writers are the tracers of running ADW processes, and WAL lets us read
|
||||
* straight through their inserts.
|
||||
*
|
||||
* ONE exception, opened lazily on its own connection: `setArchived`. Archiving
|
||||
* is review triage — "I have looked at this run" — which has to outlive a
|
||||
* browser, so it lives on the session row rather than in localStorage. It is
|
||||
* the only write this process can make, it touches exactly one column, and it
|
||||
* never runs unless a human clicks the button.
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentStartPayload,
|
||||
Envelope,
|
||||
Event,
|
||||
EventsPage,
|
||||
GateResult,
|
||||
Phase,
|
||||
Session,
|
||||
SessionDetail,
|
||||
SessionSummary,
|
||||
SessionUsage,
|
||||
} from "../shared/types.ts";
|
||||
|
||||
const DEFAULT_DB_RELATIVE = "adws/adw_data/sssf.db";
|
||||
const MAX_LIMIT = 1000;
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* Resolve the db path: --db arg wins, then SSSF_DB, then <cwd>/adws/adw_data/sssf.db.
|
||||
* The db lives in the TARGET repo, so cwd is the repo the visualizer is pointed at.
|
||||
*/
|
||||
export function resolveDbPath(argv: string[] = Bun.argv): string {
|
||||
const flagIndex = argv.indexOf("--db");
|
||||
const inline = argv.find((a) => a.startsWith("--db="));
|
||||
const raw =
|
||||
(flagIndex !== -1 ? argv[flagIndex + 1] : undefined) ??
|
||||
inline?.slice("--db=".length) ??
|
||||
process.env.SSSF_DB ??
|
||||
DEFAULT_DB_RELATIVE;
|
||||
|
||||
return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
||||
}
|
||||
|
||||
export class SssfDb {
|
||||
readonly path: string;
|
||||
/**
|
||||
* Where the ADW session dirs live: `{data_dir}/sessions/{adw_id}/{agent}/`.
|
||||
* The db sits in the same data_dir (config's `observability.db` defaults to
|
||||
* `adws/adw_data/sssf.db`), so deriving it as a sibling of the db file keeps
|
||||
* working when the whole data_dir is relocated.
|
||||
*/
|
||||
readonly sessionsDir: string;
|
||||
readonly journalMode: string;
|
||||
private readonly db: Database;
|
||||
/** Opened on first archive and kept; null until then. */
|
||||
private writer: Database | null = null;
|
||||
/** Cache for optionalColumn(), keyed "table.column". Only ever false → true. */
|
||||
private readonly columnCache = new Map<string, boolean>();
|
||||
|
||||
constructor(path: string) {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
`sssf.db not found at ${path}\n` +
|
||||
`Point the visualizer at a target repo: --db <path> or SSSF_DB=<path>, ` +
|
||||
`or run it from a repo root containing ${DEFAULT_DB_RELATIVE}`,
|
||||
);
|
||||
}
|
||||
this.path = path;
|
||||
this.sessionsDir = resolve(dirname(path), "sessions");
|
||||
this.db = new Database(path, { readonly: true });
|
||||
|
||||
// WAL is set by the tracer when it creates the db; a readonly connection
|
||||
// cannot change it, so we assert rather than set, and always take the
|
||||
// busy_timeout so a concurrent writer never turns into a failed request.
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
const mode = this.db
|
||||
.query<{ journal_mode: string }, []>("PRAGMA journal_mode")
|
||||
.get();
|
||||
this.journalMode = mode?.journal_mode ?? "unknown";
|
||||
if (this.journalMode.toLowerCase() !== "wal") {
|
||||
console.warn(
|
||||
`[db] journal_mode is "${this.journalMode}", expected "wal" — ` +
|
||||
`live reads during agent writes may block`,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A SELECT fragment for a column the tracer adds by migration.
|
||||
*
|
||||
* We open readonly and cannot run those ALTERs ourselves, so selecting one
|
||||
* blindly would throw "no such column" on every request against a db an older
|
||||
* tracer wrote. Instead we probe and substitute NULL, which reads downstream
|
||||
* as "this db predates the column" — the same thing the UI shows for a row
|
||||
* the migration didn't backfill.
|
||||
*
|
||||
* The probe re-runs while the column is missing, because the tracer's ALTER
|
||||
* can land while we're serving: a startup-only check would keep returning
|
||||
* NULL for the rest of the process even after the data arrived. Once seen,
|
||||
* a column never goes away, so it latches.
|
||||
*/
|
||||
private hasColumn(table: string, column: string): boolean {
|
||||
const key = `${table}.${column}`;
|
||||
if (!this.columnCache.get(key)) {
|
||||
const cols = this.db
|
||||
.query<{ name: string }, []>(`PRAGMA table_info(${table})`)
|
||||
.all();
|
||||
this.columnCache.set(key, cols.some((c) => c.name === column));
|
||||
}
|
||||
return this.columnCache.get(key) ?? false;
|
||||
}
|
||||
|
||||
private optionalColumn(table: string, column: string): string {
|
||||
return this.hasColumn(table, column) ? column : `NULL AS ${column}`;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.writer?.close();
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or restore a session — the only write in this process.
|
||||
*
|
||||
* busy_timeout matters: a run may be mid-insert on the same WAL db, and a
|
||||
* click should wait its turn rather than fail. Returns false when the id
|
||||
* does not exist, so the route can 404 instead of silently succeeding.
|
||||
*/
|
||||
setArchived(adwId: string, archived: boolean): boolean {
|
||||
if (!this.hasColumn("sessions", "archived")) {
|
||||
throw new Error("this db predates the archived column — run any ADW once to migrate it");
|
||||
}
|
||||
if (!this.writer) {
|
||||
this.writer = new Database(this.path);
|
||||
this.writer.exec("PRAGMA busy_timeout=5000;");
|
||||
}
|
||||
this.writer
|
||||
.query("UPDATE sessions SET archived = ? WHERE adw_id = ?")
|
||||
.run(archived ? 1 : 0, adwId);
|
||||
return this.session(adwId) !== null;
|
||||
}
|
||||
|
||||
/** Sessions, most recent first, each with its phase statuses for the progress dots. */
|
||||
sessions(limit = 200): SessionSummary[] {
|
||||
const rows = this.db
|
||||
.query<Session, [number]>(
|
||||
`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
|
||||
status, engineer, started_at, ended_at,
|
||||
total_tokens, total_cost,
|
||||
${this.optionalColumn("sessions", "archived")}
|
||||
FROM sessions
|
||||
WHERE COALESCE(${this.hasColumn("sessions", "archived") ? "archived" : "0"}, 0) = 0
|
||||
ORDER BY started_at DESC, rowid DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(clamp(limit, 1, MAX_LIMIT));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Embed each session's phases so the L1 progress dots cost no extra request.
|
||||
const ids = rows.map((row) => row.adw_id);
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
const phaseRows = this.db
|
||||
.query<Phase, string[]>(
|
||||
`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
|
||||
attempt, retries, error, started_at, ended_at
|
||||
FROM phases WHERE adw_id IN (${placeholders}) ORDER BY seq, rowid`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const byAdw = new Map<string, Phase[]>();
|
||||
for (const phase of phaseRows) {
|
||||
const list = byAdw.get(phase.adw_id);
|
||||
if (list) list.push(phase);
|
||||
else byAdw.set(phase.adw_id, [phase]);
|
||||
}
|
||||
|
||||
// Agents come along too: an L1 card draws a per-agent dot timeline, and its
|
||||
// dots are colored per agent — without this it would be one request per card.
|
||||
const agentsByAdw = this.agentsFor(ids);
|
||||
|
||||
const summaries: SessionSummary[] = [];
|
||||
for (const session of rows) {
|
||||
const phases = byAdw.get(session.adw_id) ?? [];
|
||||
summaries.push(
|
||||
Object.assign(session, {
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
agents: agentsByAdw.get(session.adw_id) ?? [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
session(adwId: string): Session | null {
|
||||
return (
|
||||
this.db
|
||||
.query<Session, [string]>(
|
||||
`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
|
||||
status, engineer, started_at, ended_at,
|
||||
total_tokens, total_cost
|
||||
FROM sessions WHERE adw_id = ?`,
|
||||
)
|
||||
.get(adwId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
phases(adwId: string): Phase[] {
|
||||
return this.db
|
||||
.query<Phase, [string]>(
|
||||
`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
|
||||
attempt, retries, error, started_at, ended_at
|
||||
FROM phases WHERE adw_id = ? ORDER BY seq, rowid`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
agentSessions(adwId: string): AgentSession[] {
|
||||
return this.agentsFor([adwId]).get(adwId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents per session, for a set of ids at once: the agent_sessions rows plus
|
||||
* anything that has started but not finished.
|
||||
*
|
||||
* agents.py writes the agent_sessions row only after the envelope persists, so
|
||||
* a running agent has no row there — precisely the case the live view exists
|
||||
* for. Its model, color and session_id are already on the agent_start event,
|
||||
* so a lane is labelled and colored from the moment the agent spawns.
|
||||
*/
|
||||
private agentsFor(adwIds: string[]): Map<string, AgentSession[]> {
|
||||
const byAdw = new Map<string, AgentSession[]>();
|
||||
if (adwIds.length === 0) return byAdw;
|
||||
const placeholders = adwIds.map(() => "?").join(", ");
|
||||
|
||||
const append = (adwId: string, agent: AgentSession) => {
|
||||
const list = byAdw.get(adwId);
|
||||
if (list) list.push(agent);
|
||||
else byAdw.set(adwId, [agent]);
|
||||
};
|
||||
|
||||
const color = this.optionalColumn("agent_sessions", "color");
|
||||
const ctxUsed = this.optionalColumn("agent_sessions", "context_tokens");
|
||||
const ctxWindow = this.optionalColumn("agent_sessions", "context_window");
|
||||
|
||||
const completed = this.db
|
||||
.query<AgentSession, string[]>(
|
||||
`SELECT adw_id, agent, coding_agent, model, session_id, ${color},
|
||||
${ctxUsed}, ${ctxWindow}, created_at, last_used_at
|
||||
FROM agent_sessions WHERE adw_id IN (${placeholders})
|
||||
ORDER BY created_at, agent`,
|
||||
)
|
||||
.all(...adwIds);
|
||||
for (const row of completed) append(row.adw_id, row);
|
||||
|
||||
const started = this.db
|
||||
.query<
|
||||
{
|
||||
adw_id: string;
|
||||
agent: string | null;
|
||||
payload_json: string | null;
|
||||
started_at: string | null;
|
||||
},
|
||||
string[]
|
||||
>(
|
||||
`SELECT e.adw_id, p.owner AS agent, e.payload_json, e.started_at
|
||||
FROM events e JOIN phases p ON p.phase_id = e.phase_id
|
||||
WHERE e.adw_id IN (${placeholders}) AND e.type = 'agent_start'
|
||||
ORDER BY e.rowid`,
|
||||
)
|
||||
.all(...adwIds);
|
||||
|
||||
for (const row of started) {
|
||||
if (!row.agent) continue;
|
||||
// A finished row is authoritative; only fill genuine gaps.
|
||||
if (byAdw.get(row.adw_id)?.some((a) => a.agent === row.agent)) continue;
|
||||
let payload: AgentStartPayload = {};
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json ?? "{}") as AgentStartPayload;
|
||||
} catch {
|
||||
// A malformed payload just means no label — never a failed request.
|
||||
}
|
||||
append(row.adw_id, {
|
||||
adw_id: row.adw_id,
|
||||
agent: row.agent,
|
||||
coding_agent: null,
|
||||
model: payload.model ?? null,
|
||||
session_id: payload.session_id ?? null,
|
||||
color: payload.color ?? null,
|
||||
// Occupancy is only known once the agent's turn closes.
|
||||
context_tokens: null,
|
||||
context_window: null,
|
||||
created_at: row.started_at,
|
||||
last_used_at: row.started_at,
|
||||
});
|
||||
}
|
||||
return byAdw;
|
||||
}
|
||||
|
||||
/** Session + phases + agents in one shot — L2 needs all three to draw lanes. */
|
||||
sessionDetail(adwId: string): SessionDetail | null {
|
||||
const session = this.session(adwId);
|
||||
if (!session) return null;
|
||||
|
||||
return {
|
||||
session,
|
||||
usage: this.usage(adwId),
|
||||
phases: this.phases(adwId),
|
||||
agents: this.agentSessions(adwId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw tokens read and written, beside the billed headline.
|
||||
*
|
||||
* Derived from the `agent_end` payloads rather than stored, so every run
|
||||
* already in the db gets the split without a migration or a re-run.
|
||||
*
|
||||
* `total_tokens` is a SPEND number: every turn re-sends the whole
|
||||
* conversation, so an 86k conversation over 49 turns bills millions. These
|
||||
* two say what actually moved — material read for the first time, and
|
||||
* material generated. The gap between them and the headline is cached
|
||||
* re-reads, which is usually most of it.
|
||||
*/
|
||||
usage(adwId: string): SessionUsage {
|
||||
const rows = this.db
|
||||
.query<{ payload_json: string | null }, [string]>(
|
||||
"SELECT payload_json FROM events WHERE adw_id = ? AND type = 'agent_end'",
|
||||
)
|
||||
.all(adwId);
|
||||
|
||||
let read = 0;
|
||||
let written = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.payload_json) continue;
|
||||
try {
|
||||
const u = (JSON.parse(row.payload_json) as { usage?: Record<string, number> }).usage;
|
||||
if (!u) continue;
|
||||
// RAW reads only: material entering the context for the first time,
|
||||
// billed either as uncached input or as a cache write. Cache reads are
|
||||
// the same tokens served again on later turns — counting them here
|
||||
// would rebuild the very inflation this split exists to expose.
|
||||
read += (u.input_tokens ?? 0) + (u.cache_write_tokens ?? 0);
|
||||
written += u.output_tokens ?? 0;
|
||||
} catch {
|
||||
/* a payload written by an older tracer simply contributes nothing */
|
||||
}
|
||||
}
|
||||
return { read, written };
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling query. Rowid cursor, insertion order, bounded page — the same
|
||||
* mechanism serves the live tail and lazy-paged history.
|
||||
*/
|
||||
events(adwId: string, after = 0, limit = DEFAULT_LIMIT): EventsPage {
|
||||
const cappedLimit = clamp(limit, 1, MAX_LIMIT);
|
||||
const events = this.db
|
||||
.query<Event, [string, number, number]>(
|
||||
`SELECT rowid, event_id, adw_id, phase_id, parent_id, type, name,
|
||||
payload_json, tokens, started_at, ended_at
|
||||
FROM events
|
||||
WHERE adw_id = ? AND rowid > ?
|
||||
ORDER BY rowid
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(adwId, Math.max(0, after), cappedLimit);
|
||||
|
||||
return {
|
||||
events,
|
||||
cursor: events.length > 0 ? events[events.length - 1]!.rowid : Math.max(0, after),
|
||||
has_more: events.length === cappedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
envelopes(adwId: string): Envelope[] {
|
||||
return this.db
|
||||
.query<Envelope, [string]>(
|
||||
`SELECT envelope_id, adw_id, phase_id, agent, output_type, payload_json,
|
||||
valid, attempt, created_at
|
||||
FROM envelopes WHERE adw_id = ? ORDER BY created_at, rowid`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
gates(adwId: string): GateResult[] {
|
||||
const checks = this.optionalColumn("gate_results", "checks_json");
|
||||
return this.db
|
||||
.query<GateResult, [string]>(
|
||||
`SELECT id, adw_id, phase_id, attempt, gate, passed, violations_json,
|
||||
${checks}, created_at
|
||||
FROM gate_results WHERE adw_id = ? ORDER BY id`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
sessionCount(): number {
|
||||
const row = this.db
|
||||
.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM sessions")
|
||||
.get();
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
261
sssf/apps/visualizer/server/index.ts
Normal file
261
sssf/apps/visualizer/server/index.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* SSSF visualizer server — JSON API over one or more repos' sssf.db, plus the
|
||||
* built UI when ./dist exists. Reads are read-only; the single write is
|
||||
* POST /api/sessions/:adw_id/archive, which sets one review flag on a row.
|
||||
*
|
||||
* There is no ingest endpoint and no websocket. The data path is
|
||||
* agents → sqlite → web ui, and the UI gets there by polling.
|
||||
*
|
||||
* Single repo (legacy):
|
||||
* bun run server/index.ts
|
||||
* bun run server/index.ts --db /path/to/repo/adws/adw_data/sssf.db
|
||||
* SSSF_DB=/path/to/sssf.db PORT=4600 bun run server/index.ts
|
||||
*
|
||||
* Multi repo:
|
||||
* SSSF_REPOS=/path/to/repos.json PORT=4600 bun run server/index.ts
|
||||
*
|
||||
* In multi-repo mode every route is prefixed with the repo slug:
|
||||
* /api/repos
|
||||
* /api/:repo/sessions
|
||||
* /api/:repo/sessions/:adw_id
|
||||
* ...
|
||||
* The legacy unprefixed routes (/api/sessions, ...) are kept and resolve to
|
||||
* the first repo, so a single-repo deployment and the SPA's default view
|
||||
* keep working unchanged.
|
||||
*/
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { buildRepos, type RepoRuntime } from "./repos.ts";
|
||||
import type { AgentPrompts, ApiError, HealthResponse } from "../shared/types.ts";
|
||||
import type { Server } from "bun";
|
||||
const PORT = Number(process.env.PORT ?? 4600);
|
||||
const DIST_DIR = resolve(import.meta.dir, "..", "dist");
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return json({ error: message } satisfies ApiError, 404);
|
||||
}
|
||||
|
||||
/** Guard every handler so a malformed query can't take the server down mid-run. */
|
||||
function safely(
|
||||
handler: (req: Request) => Response | Promise<Response>,
|
||||
): (req: Request) => Promise<Response> {
|
||||
return async (req) => {
|
||||
try {
|
||||
return await handler(req);
|
||||
} catch (error) {
|
||||
console.error(`[sssf] ${req.method} ${new URL(req.url).pathname}:`, error);
|
||||
return json({ error: (error as Error).message } satisfies ApiError, 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* adw_ids and agent names are path segments on disk, so anything that isn't a
|
||||
* plain identifier is rejected outright rather than sanitized into something
|
||||
* that might still escape the sessions directory.
|
||||
*/
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function isSafeSegment(value: string): boolean {
|
||||
return SAFE_SEGMENT.test(value) && value !== "." && value !== "..";
|
||||
}
|
||||
|
||||
function param(req: Request, key: string): string {
|
||||
return decodeURIComponent(
|
||||
(req as Request & { params: Record<string, string> }).params[key] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
function intQuery(req: Request, key: string, fallback: number): number {
|
||||
const raw = new URL(req.url).searchParams.get(key);
|
||||
if (raw === null || raw.trim() === "") return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Serve the built SPA if it has been built; otherwise point at the dev server. */
|
||||
async function serveStatic(req: Request): Promise<Response> {
|
||||
const { pathname } = new URL(req.url);
|
||||
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
return new Response(
|
||||
`SSSF visualizer API is running on :${PORT}.\n\n` +
|
||||
`No ./dist build found. Run "bun run dev" for the Vite dev server ` +
|
||||
`(it proxies /api here), or "bun run build" to serve the UI from this process.\n`,
|
||||
{ status: 200, headers: { "content-type": "text/plain; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Reject traversal before touching the filesystem.
|
||||
const candidate = resolve(join(DIST_DIR, pathname));
|
||||
if (candidate === DIST_DIR || candidate.startsWith(DIST_DIR + "/")) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return new Response(Bun.file(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback: breadcrumb routes are client-side.
|
||||
const indexHtml = join(DIST_DIR, "index.html");
|
||||
if (existsSync(indexHtml)) {
|
||||
return new Response(Bun.file(indexHtml), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return notFound("not found");
|
||||
}
|
||||
|
||||
/** Build the route table for one repo, mounted at /api/:repo and /api (default). */
|
||||
function repoRoutes(repo: RepoRuntime, prefix: string) {
|
||||
const db = repo.db;
|
||||
const p = (path: string) => `${prefix}${path}`;
|
||||
return {
|
||||
[p("/health")]: safely(
|
||||
() =>
|
||||
json({
|
||||
ok: true,
|
||||
repo: repo.slug,
|
||||
db: db.path,
|
||||
journal_mode: db.journalMode,
|
||||
sessions: db.sessionCount(),
|
||||
} satisfies HealthResponse),
|
||||
),
|
||||
|
||||
[p("/sessions")]: safely((req) => json(db.sessions(intQuery(req, "limit", 200)))),
|
||||
|
||||
[p("/sessions/:adw_id")]: safely((req) => {
|
||||
const detail = db.sessionDetail(param(req, "adw_id"));
|
||||
return detail ? json(detail) : notFound(`no session ${param(req, "adw_id")}`);
|
||||
}),
|
||||
|
||||
// The one write. Archiving is review triage — it belongs to the reader, not
|
||||
// to the run — so it never touches anything a tracer wrote.
|
||||
[p("/sessions/:adw_id/archive")]: {
|
||||
POST: safely(async (req) => {
|
||||
const adwId = param(req, "adw_id");
|
||||
if (!isSafeSegment(adwId)) {
|
||||
return json({ error: "invalid adw_id" } satisfies ApiError, 400);
|
||||
}
|
||||
const body = (await req.json().catch(() => ({}))) as { archived?: unknown };
|
||||
const archived = body.archived === undefined ? true : Boolean(body.archived);
|
||||
return db.setArchived(adwId, archived)
|
||||
? json({ adw_id: adwId, archived })
|
||||
: notFound(`no session ${adwId}`);
|
||||
}),
|
||||
},
|
||||
|
||||
[p("/sessions/:adw_id/events")]: safely((req) =>
|
||||
json(
|
||||
db.events(
|
||||
param(req, "adw_id"),
|
||||
intQuery(req, "after", 0),
|
||||
intQuery(req, "limit", 500),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
[p("/sessions/:adw_id/envelopes")]: safely((req) =>
|
||||
json(db.envelopes(param(req, "adw_id"))),
|
||||
),
|
||||
|
||||
[p("/sessions/:adw_id/gates")]: safely((req) => json(db.gates(param(req, "adw_id")))),
|
||||
|
||||
// The exact prompts an agent was sent, read from the session dir. Files are
|
||||
// the raw record; the db has no copy of them.
|
||||
[p("/sessions/:adw_id/agents/:agent/prompts")]: safely(async (req) => {
|
||||
const adwId = param(req, "adw_id");
|
||||
const agent = param(req, "agent");
|
||||
if (!isSafeSegment(adwId) || !isSafeSegment(agent)) {
|
||||
return json({ error: "invalid adw_id or agent" } satisfies ApiError, 400);
|
||||
}
|
||||
if (!db.session(adwId)) return notFound(`no session ${adwId}`);
|
||||
|
||||
const dir = resolve(db.sessionsDir, adwId, agent, "prompts");
|
||||
// Defense in depth: the segment check already forbids traversal.
|
||||
if (dir !== db.sessionsDir && !dir.startsWith(db.sessionsDir + sep)) {
|
||||
return json({ error: "invalid path" } satisfies ApiError, 400);
|
||||
}
|
||||
|
||||
// A prompt file is absent whenever the agent never ran in this session —
|
||||
// a normal state, so it reads as null rather than an error.
|
||||
const read = async (name: string): Promise<string | null> => {
|
||||
const file = Bun.file(join(dir, `${name}.md`));
|
||||
return (await file.exists()) ? await file.text() : null;
|
||||
};
|
||||
return json({
|
||||
system: await read("system"),
|
||||
user: await read("user"),
|
||||
} satisfies AgentPrompts);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// The registry is mutable: POST /api/reload re-reads repos.json and swaps the
|
||||
// route table in place (Bun's server.reload), so new repos appear without a
|
||||
// process restart. `server` is assigned below; the reload handler only runs on
|
||||
// a request, by which point it is set.
|
||||
let repos: RepoRuntime[] = (await buildRepos()).repos;
|
||||
let server: Server<undefined>;
|
||||
|
||||
/** Build the route table for the current registry. */
|
||||
function buildRoutes(repos: RepoRuntime[]): Record<string, unknown> {
|
||||
const routes: Record<string, unknown> = {
|
||||
"/api/repos": safely(() =>
|
||||
json(repos.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path }))),
|
||||
),
|
||||
// Re-read repos.json and swap the registry + routes in place. The UI calls
|
||||
// this after a repo is added/removed, so it shows up without a restart.
|
||||
"/api/reload": {
|
||||
POST: safely(async () => {
|
||||
const next = (await buildRepos()).repos;
|
||||
repos = next;
|
||||
server.reload({ routes: buildRoutes(next) as never });
|
||||
return json({
|
||||
ok: true,
|
||||
repos: next.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path })),
|
||||
});
|
||||
}),
|
||||
},
|
||||
};
|
||||
for (const repo of repos) {
|
||||
Object.assign(routes, repoRoutes(repo, `/api/${repo.slug}`));
|
||||
}
|
||||
Object.assign(routes, repoRoutes(repos[0], "/api"));
|
||||
return routes;
|
||||
}
|
||||
|
||||
const routes = buildRoutes(repos);
|
||||
|
||||
server = Bun.serve({
|
||||
port: PORT,
|
||||
routes: routes as never,
|
||||
fetch(req) {
|
||||
const { pathname } = new URL(req.url);
|
||||
if (pathname.startsWith("/api/")) return notFound(`no route ${pathname}`);
|
||||
return serveStatic(req);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[sssf] visualizer api http://localhost:${server.port}`);
|
||||
for (const repo of repos) {
|
||||
console.log(`[sssf] repo ${repo.slug} ${repo.db.path} [journal_mode=${repo.db.journalMode}]`);
|
||||
}
|
||||
console.log(
|
||||
existsSync(DIST_DIR)
|
||||
? `[sssf] serving ui from ${DIST_DIR}`
|
||||
: `[sssf] no ./dist — use "bun run dev" for the Vite dev server on :4601`,
|
||||
);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
146
sssf/apps/visualizer/server/repos.ts
Normal file
146
sssf/apps/visualizer/server/repos.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Repo registry for the multi-repo visualizer.
|
||||
*
|
||||
* The visualizer can serve several repos' trace DBs from one process. A repo
|
||||
* is a slug → sssf.db mapping, declared in a JSON file:
|
||||
*
|
||||
* [
|
||||
* { "slug": "tailsandstays", "name": "Tails & Stays",
|
||||
* "db": "/home/ima/projects/tailsandstays/adws/adw_data/sssf.db" },
|
||||
* { "slug": "mempalace", "name": "MemPalace",
|
||||
* "db": "/home/ima/projects/mempalace/adws/adw_data/sssf.db" }
|
||||
* ]
|
||||
*
|
||||
* Point the server at it with SSSF_REPOS=/path/to/repos.json. When SSSF_REPOS
|
||||
* is unset the server auto-discovers repos under a projects root (default
|
||||
* ~/projects, override with SSSF_PROJECTS_ROOT) by scanning for a trace db at
|
||||
* <root>/<dir>/adws/adw_data/sssf.db. If nothing is discovered it falls back to
|
||||
* the single-repo behaviour (--db / SSSF_DB / <cwd>/adws/adw_data/sssf.db)
|
||||
* under a slug derived from the repo dir name, so the original single-repo
|
||||
* deployment keeps working.
|
||||
*/
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { SssfDb, resolveDbPath } from "./db.ts";
|
||||
|
||||
export interface RepoEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
db: string;
|
||||
}
|
||||
|
||||
export interface RepoRuntime {
|
||||
slug: string;
|
||||
name: string;
|
||||
db: SssfDb;
|
||||
}
|
||||
|
||||
const SAFE_SLUG = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function isSafeSlug(slug: string): boolean {
|
||||
return SAFE_SLUG.test(slug) && slug !== "." && slug !== "..";
|
||||
}
|
||||
|
||||
/** Derive a display slug from a db path: <repo>/adws/adw_data/sssf.db → repo dir name. */
|
||||
function slugFromDb(dbPath: string): string {
|
||||
// adw_data → adws → repo root
|
||||
const repoDir = dirname(dirname(dirname(dbPath)));
|
||||
const name = basename(repoDir);
|
||||
return name && isSafeSlug(name) ? name : "default";
|
||||
}
|
||||
|
||||
async function loadReposFile(path: string): Promise<RepoEntry[]> {
|
||||
const raw = Bun.file(path);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`SSSF_REPOS file not found: ${path}`);
|
||||
}
|
||||
const data = JSON.parse(await raw.text()) as unknown;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SSSF_REPOS file must be a JSON array of { slug, name, db }`);
|
||||
}
|
||||
const entries: RepoEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of data) {
|
||||
const entry = item as Partial<RepoEntry>;
|
||||
if (typeof entry.slug !== "string" || !isSafeSlug(entry.slug)) {
|
||||
throw new Error(`invalid repo slug: ${String(entry.slug)}`);
|
||||
}
|
||||
if (typeof entry.db !== "string") {
|
||||
throw new Error(`repo "${entry.slug}" is missing a db path`);
|
||||
}
|
||||
if (seen.has(entry.slug)) {
|
||||
throw new Error(`duplicate repo slug: ${entry.slug}`);
|
||||
}
|
||||
seen.add(entry.slug);
|
||||
const db = isAbsolute(entry.db) ? entry.db : resolve(dirname(path), entry.db);
|
||||
entries.push({
|
||||
slug: entry.slug,
|
||||
name: typeof entry.name === "string" && entry.name ? entry.name : entry.slug,
|
||||
db,
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
throw new Error("SSSF_REPOS file declares no repos");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover repos under a projects root by scanning for a trace db at
|
||||
* <root>/<dir>/adws/adw_data/sssf.db. Used when SSSF_REPOS is unset so new
|
||||
* repos appear without editing a registry file. The root defaults to
|
||||
* ~/projects and can be overridden with SSSF_PROJECTS_ROOT.
|
||||
*/
|
||||
function discoverRepos(root: string): RepoEntry[] {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const entries: RepoEntry[] = [];
|
||||
for (const name of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!name.isDirectory() || name.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const db = join(root, name.name, "adws", "adw_data", "sssf.db");
|
||||
if (existsSync(db) && isSafeSlug(name.name)) {
|
||||
entries.push({ slug: name.name, name: name.name, db });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the repo registry. The first repo is served under the legacy
|
||||
* unprefixed /api routes (the single repo in single-repo mode, or the first
|
||||
* repo in multi-repo mode).
|
||||
*/
|
||||
export async function buildRepos(): Promise<{ repos: RepoRuntime[] }> {
|
||||
const reposFile = process.env.SSSF_REPOS;
|
||||
const entries: RepoEntry[] = reposFile
|
||||
? await loadReposFile(reposFile)
|
||||
: discoverRepos(process.env.SSSF_PROJECTS_ROOT ?? join(homedir(), "projects"));
|
||||
if (entries.length === 0) {
|
||||
// No discovered repos — fall back to the single-repo behaviour so the
|
||||
// original deployment (--db / SSSF_DB / <cwd>/adws/adw_data/sssf.db) works.
|
||||
entries.push({ slug: slugFromDb(resolveDbPath()), name: "", db: resolveDbPath() });
|
||||
}
|
||||
|
||||
const repos: RepoRuntime[] = [];
|
||||
for (const entry of entries) {
|
||||
let db: SssfDb;
|
||||
try {
|
||||
db = new SssfDb(entry.db);
|
||||
} catch (error) {
|
||||
console.error(`[sssf] repo "${entry.slug}": ${(error as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
repos.push({ slug: entry.slug, name: entry.name, db });
|
||||
}
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.error("[sssf] no repos could be opened — exiting");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { repos };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue