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
This commit is contained in:
172
apps/api/src/sessions/routes.ts
Normal file
172
apps/api/src/sessions/routes.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user