feat(backend): rename/group/archive/unarchive/delete/documents routes + resume read-only guard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 12:32:52 +02:00
co-authored by Claude Opus 4.8
parent 4a63db51e8
commit bd29ac517c
3 changed files with 97 additions and 5 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
app.register(cors, {
origin: true,
credentials: true,
methods: ["GET", "POST", "PUT", "OPTIONS"],
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
});
const tht = deps?.thtRunner ?? new ThtRunner({
+27
View File
@@ -48,6 +48,10 @@ export function sessionRoutes(
});
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const manifest = (await d.tht.sessionShow(id)) as { status?: string; archived?: boolean } | null;
if (manifest?.status === "finalized" || manifest?.archived) {
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
}
const rt = await d.mgr.resume(id, d.tht);
rt.bridge.onClientEvent((e) => d.hub.publish(id, e.type, e));
return reply.code(200).send({ id });
@@ -73,4 +77,27 @@ export function sessionRoutes(
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
req.raw.on("close", off);
});
app.post("/sessions/:id/rename", async (req, reply) => {
await d.tht.setName((req.params as any).id, (req.body as any).name);
return reply.code(204).send();
});
app.post("/sessions/:id/group", async (req, reply) => {
await d.tht.setGroup((req.params as any).id, (req.body as any).group);
return reply.code(204).send();
});
app.post("/sessions/:id/archive", async (req, reply) => {
await d.tht.archive((req.params as any).id);
return reply.code(204).send();
});
app.post("/sessions/:id/unarchive", async (req, reply) => {
await d.tht.unarchive((req.params as any).id);
return reply.code(204).send();
});
app.delete("/sessions/:id", async (req, reply) => {
const id = (req.params as any).id;
d.mgr.teardown(id); // drop any live runtime before deleting on disk
await d.tht.deleteSession(id);
return reply.code(204).send();
});
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
}
+69 -4
View File
@@ -1,5 +1,5 @@
import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import { spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
@@ -7,6 +7,14 @@ 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");
function mutApp(thtRunner: any) {
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner,
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
}
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
let sessionNewArg: any;
let spawnArg: any;
@@ -16,7 +24,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
sessionList: async () => [{ id: "s1" }],
} as any,
getSettings: () => ({ workspace: "w", provider: "zai", model: "glm-5.2", thinking: "high" }),
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(created.json()).toEqual({ id: "s1" });
@@ -31,12 +39,69 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
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,
thtRunner: {
sessionNew: async () => ({ id: "s1" }),
sessionList: async () => [],
} as any,
getSettings: () => ({ workspace: "w" }),
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { 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);
});
test("POST /sessions/:id/rename calls setName", async () => {
let arg: any;
const app = mutApp({ setName: async (id: string, name: string) => { arg = { id, name }; } });
const res = await app.inject({ method: "POST", url: "/sessions/s1/rename", payload: { name: "N" } });
expect(res.statusCode).toBe(204);
expect(arg).toEqual({ id: "s1", name: "N" });
});
test("POST /sessions/:id/group calls setGroup", async () => {
let arg: any;
const app = mutApp({ setGroup: async (id: string, group: string) => { arg = { id, group }; } });
const res = await app.inject({ method: "POST", url: "/sessions/s1/group", payload: { group: "G" } });
expect(res.statusCode).toBe(204);
expect(arg).toEqual({ id: "s1", group: "G" });
});
test("POST archive / unarchive call the runner", async () => {
const seen: string[] = [];
const app = mutApp({
archive: async (id: string) => { seen.push(`a:${id}`); },
unarchive: async (id: string) => { seen.push(`u:${id}`); },
});
expect((await app.inject({ method: "POST", url: "/sessions/s1/archive" })).statusCode).toBe(204);
expect((await app.inject({ method: "POST", url: "/sessions/s1/unarchive" })).statusCode).toBe(204);
expect(seen).toEqual(["a:s1", "u:s1"]);
});
test("DELETE /sessions/:id calls deleteSession", async () => {
let deleted: string | null = null;
const app = mutApp({ deleteSession: async (id: string) => { deleted = id; } });
const res = await app.inject({ method: "DELETE", url: "/sessions/s1" });
expect(res.statusCode).toBe(204);
expect(deleted).toBe("s1");
});
test("GET /sessions/:id/documents returns the runner output", async () => {
const app = mutApp({ documents: async () => [{ phase: "—", key: "question", title: "t", format: "text", content: "q" }] });
const res = await app.inject({ method: "GET", url: "/sessions/s1/documents" });
expect(res.statusCode).toBe(200);
expect(res.json()[0].key).toBe("question");
});
test("POST resume on a finalized session is refused with 409", async () => {
const app = mutApp({ sessionShow: async () => ({ status: "finalized", archived: false }) });
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
expect(res.statusCode).toBe(409);
});
test("POST resume on an archived session is refused with 409", async () => {
const app = mutApp({ sessionShow: async () => ({ status: "open", archived: true }) });
const res = await app.inject({ method: "POST", url: "/sessions/s1/resume" });
expect(res.statusCode).toBe(409);
});