diff --git a/backend/src/app.ts b/backend/src/app.ts index 5bbdcdcb..c8a20d0f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,7 +1,30 @@ import Fastify, { type FastifyInstance } from "fastify"; import type { AppConfig } from "./config.js"; -export function buildApp(_config: AppConfig): FastifyInstance { +import { ThtRunner } from "./tht/tht-runner.js"; +import { PiProcessManager } from "./pi/pi-process-manager.js"; +import { SseHub } from "./sse/sse-hub.js"; +import { authPreHandler } from "./auth/auth.js"; +import { sessionRoutes } from "./routes/sessions.js"; + +export interface BuildAppDeps { + thtRunner?: ThtRunner; + spawnFn?: () => any; +} + +export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { const app = Fastify({ logger: false }); + + const tht = deps?.thtRunner ?? new ThtRunner({ + thtBin: config.thtBin, + harnessDir: config.harnessDir, + configPath: "config/tht.yaml", + }); + const mgr = new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); + const hub = new SseHub(); + + app.addHook("preHandler", authPreHandler(config.authMode)); app.get("/health", async () => ({ status: "ok" })); + sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub }); + return app; } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts new file mode 100644 index 00000000..1a2ae8c0 --- /dev/null +++ b/backend/src/routes/sessions.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; +import type { PiProcessManager } from "../pi/pi-process-manager.js"; +import type { ThtRunner } from "../tht/tht-runner.js"; +import type { SseHub } from "../sse/sse-hub.js"; +import { getUser } from "../auth/auth.js"; + +export function sessionRoutes(app: FastifyInstance, d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub }) { + app.post("/sessions", async (req, reply) => { + const b = req.body as any; + const { id } = await d.tht.sessionNew({ question: b.question, provider: b.provider, model: b.model, thinking: b.thinking, name: b.name }); + const rt = await d.mgr.spawnFor(id, { provider: b.provider, model: b.model, thinking: b.thinking, author: getUser(req).id }); + rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e)); + return { id }; + }); + app.get("/sessions", async () => d.tht.sessionList()); + app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id)); + app.post("/sessions/:id/response", async (req, reply) => { + const id = (req.params as any).id; + const rt = d.mgr.get(id); + if (!rt) return reply.code(404).send({ error: "sessione non attiva" }); + rt.bridge.respond((req.body as any).ui_response); + return reply.code(204).send(); + }); + app.post("/sessions/:id/steer", async (req, reply) => { + const rt = d.mgr.get((req.params as any).id); + if (!rt) return reply.code(404).send({ error: "sessione non attiva" }); + rt.bridge.steer((req.body as any).text); + return reply.code(204).send(); + }); + app.post("/sessions/:id/close", async (req) => { + d.mgr.teardown((req.params as any).id); + return { closed: true }; + }); + app.get("/sessions/:id/events", (req, reply) => { + const id = (req.params as any).id; + const rt = d.mgr.get(id); + reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); + const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null); + req.raw.on("close", off); + }); +} diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts new file mode 100644 index 00000000..8cf285e9 --- /dev/null +++ b/backend/test/routes-sessions.test.ts @@ -0,0 +1,30 @@ +import { test, expect } from "vitest"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs"); +const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json"); + +test("POST /sessions crea e avvia, GET /sessions lista", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { sessionNew: async () => ({ id: "s1" }), sessionList: async () => [{ id: "s1" }] } as any, + spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + }); + const created = await app.inject({ method: "POST", url: "/sessions", payload: { workspace: "w", question: "q" } }); + expect(created.json()).toEqual({ id: "s1" }); + const list = await app.inject({ method: "GET", url: "/sessions" }); + expect(list.json()).toEqual([{ id: "s1" }]); +}); + +test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { sessionNew: async () => ({ id: "s1" }) } as any, + spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any, + }); + await app.inject({ method: "POST", url: "/sessions", payload: { workspace: "w", question: "q" } }); + const res = await app.inject({ method: "POST", url: "/sessions/s1/response", + payload: { ui_response: { id: "u1", choices: ["a"] } } }); + expect(res.statusCode).toBe(204); +});