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.
261 lines
9.2 KiB
TypeScript
261 lines
9.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|