Files
sic/apps/api/src/sessions/routes.ts
rikrdo 62728b2200 Initial commit: SIC harness (backend, web, pi-adapter, configs, docs)
- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter
- Local auth (admin/webhook-runner roles) + Keycloak JWT ready
- Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream)
- Markdown knowledge base with /api/docs/search + /api/docs/:id
- YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit
- Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt
- LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override)
- Context-aware webhooks panel + backend id-mention safety net
- Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons
- Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override
- /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation
- dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env)
- Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
2026-06-29 16:20:53 +02:00

173 lines
5.9 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getAuthUser } from "../auth/index.js";
import type { AppDatabase } from "../db/database.js";
import { createMessageRepository, createSessionRepository } from "./repository.js";
const createSessionBody = z.object({
title: z.string().min(1).max(120).optional(),
});
const updateSessionBody = z.object({
title: z.string().trim().min(1).max(120),
});
const updateSystemPromptBody = z.object({
// Empty / whitespace-only strings clear the override; null is a no-op.
system_prompt: z.string().max(8_000).nullable().optional(),
});
export async function registerSessionRoutes(app: FastifyInstance, db: AppDatabase) {
const sessions = createSessionRepository(db);
const messages = createMessageRepository(db);
app.get("/api/sessions", async (request) => {
const user = await getAuthUser(request);
return { items: sessions.list(user.id) };
});
app.post("/api/sessions", async (request, reply) => {
const user = await getAuthUser(request);
const body = createSessionBody.parse(request.body ?? {});
const session = sessions.create(user.id, body.title ?? null);
return reply.code(201).send(session);
});
app.get<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
const user = await getAuthUser(request);
const session = sessions.get(user.id, request.params.id);
if (!session) {
return reply.code(404).send({ error: "session_not_found" });
}
return {
...session,
messages: messages.listForSession(user.id, session.id),
};
});
app.patch<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
const user = await getAuthUser(request);
const body = updateSessionBody.parse(request.body);
const session = sessions.get(user.id, request.params.id);
if (!session) {
return reply.code(404).send({ error: "session_not_found" });
}
sessions.updateTitle(user.id, session.id, body.title);
return sessions.get(user.id, session.id);
});
// Per-session system prompt override. Inserted into the chat stream
// immediately after the base identity prompt, before the docs/actions
// context. Use to attach incident-specific context (runbook link, on-call
// names, severity matrix) without polluting the global prompt.
app.patch<{ Params: { id: string } }>(
"/api/sessions/:id/system-prompt",
async (request, reply) => {
const user = await getAuthUser(request);
const body = updateSystemPromptBody.parse(request.body ?? {});
const session = sessions.get(user.id, request.params.id);
if (!session) {
return reply.code(404).send({ error: "session_not_found" });
}
sessions.updateSystemPrompt(user.id, session.id, body.system_prompt ?? null);
return sessions.get(user.id, session.id);
},
);
app.delete<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
const user = await getAuthUser(request);
const deleted = sessions.delete(user.id, request.params.id);
if (!deleted) {
return reply.code(404).send({ error: "session_not_found" });
}
return reply.code(204).send();
});
// Bulk delete: wipes every session owned by the current user. Cascade
// removes the messages and webhook_runs that point at them. The frontend
// requires the user to type the literal word "delete" before this fires.
app.delete("/api/sessions", async (request, reply) => {
const user = await getAuthUser(request);
const removed = sessions.deleteAllForUser(user.id);
return reply.code(200).send({ deleted: removed });
});
// Export: returns a JSON document with the session metadata and all its
// messages. The shape is a stable contract so a `POST /api/sessions/import`
// can read it back. webhook_runs are intentionally excluded from the
// export — those are operational audit data, not conversation content.
app.get<{ Params: { id: string } }>("/api/sessions/:id/export", async (request, reply) => {
const user = await getAuthUser(request);
const session = sessions.get(user.id, request.params.id);
if (!session) {
return reply.code(404).send({ error: "session_not_found" });
}
return {
version: 1,
exported_at: new Date().toISOString(),
session: {
id: session.id,
title: session.title,
created_at: session.created_at,
updated_at: session.updated_at,
},
messages: messages.listForSession(user.id, session.id),
};
});
// Import: accepts the export document above, creates a new session owned
// by the caller, and writes the messages with fresh ids. Returns the new
// session id and a count of imported messages.
const importSessionBody = z.object({
session: z.object({
title: z.string().max(120).nullable().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
}),
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string().min(1).max(50_000),
metadata: z.record(z.unknown()).optional(),
// Original created_at is preserved if present; otherwise "now" is
// used. Used only to restore the timeline.
created_at: z.string().optional(),
}),
),
});
app.post("/api/sessions/import", async (request, reply) => {
const user = await getAuthUser(request);
const body = importSessionBody.parse(request.body);
const newSession = sessions.create(user.id, body.session.title ?? null);
let imported = 0;
for (const message of body.messages) {
messages.create({
sessionId: newSession.id,
userId: user.id,
role: message.role,
content: message.content,
metadata: message.metadata,
});
imported += 1;
}
sessions.touch(user.id, newSession.id);
return reply.code(201).send({
session: newSession,
imported_messages: imported,
});
});
}