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:
INDigitalStudio 2026-08-09 21:00:28 +00:00
parent a42608f602
commit 2cc766aabe
98 changed files with 12508 additions and 0 deletions

View 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)));
}

View 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);
});

View 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 };
}