Lo spike Task 1 ha provato che ctx.sendRaw non esiste e pi.on(extension_ui_response) non e' dispatchato. Aggiornati: Piano1 Task2 (mock ctx.ui), Task4 (rewrite gate a ctx.ui.input con descriptor in title), Task10 (fake-pi-rpc shape nativa); Piano2 Task3/Task4 (SessionBridge decodifica title<->value). Contratto FE invariato. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 KiB
Backend Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Costruire il backend ThothII: un orchestratore/traduttore Node+Fastify+TS che avvia Pi in RPC (un processo per sessione), fa da ponte JSONL↔(SSE/REST) verso il frontend, delega a tht l'esecuzione del SQL finale, e applica auth pluggabile.
Architecture: Backend senza stato persistente proprio (verità su disco harness). Un RpcClient (framing LF-only) per ogni processo Pi gestito dal PiProcessManager (uno per sessione attiva). Il SessionBridge traduce extension_ui_request↔ui_request correlando per id e mantiene il widget pendente per il re-emit su riconnessione SSE. Le interazioni con il modello (prompt/steer/set_model/set_thinking/get_available_models) usano i comandi RPC nativi di Pi. Il SQL finale è delegato a tht sql preview/export (nessun client DB nel backend). Tutto testabile in CI contro il fake-pi-rpc consegnato dal Piano Harness.
Tech Stack: Node ≥ 20, TypeScript (ESM), Fastify 5, vitest (test), tsx (dev run). Pi = @earendil-works/pi-coding-agent (pi --mode rpc). CLI tht (Python, già installato nel venv harness).
Global Constraints
- Prerequisito: il Piano Harness (
2026-06-27-harness-rpc-readiness.md) deve essere completato — il backend dipende da: gate RPC-ready,THT_SESSIONid injection,tht sql preview --json/--offset,tht session list/show --json, campi manifest,fake-pi-rpc. - Framing RPC: LF-only JSONL —
JSON.stringify(v)+"\n"; lettura split su\n, strip\rfinale. MAIreadline. Riferimento:@earendil-works/pi-coding-agent/dist/modes/rpc/jsonl.js. - WIRE CONTRACT (corretto post-spike, Piano Harness Task 1): il gate usa l'API UI nativa di Pi. Sul wire arriva
{type:"extension_ui_request", id, method:"input", title:"<widget-descriptor JSON>"}; il backend decodificatitle→ descriptor, lo espone al FE comeui_request, e risponde{type:"extension_ui_response", id, value:"<ui_response JSON>"}(o{id, cancelled:true}).ctx.sendRawnon esiste; non esistono eventi{ui_request:…}nidificati. Il contratto verso il FE (ui_request/ui_response, architettura §4) resta invariato — la traduzione native↔FE è responsabilità delSessionBridge. - Deployment MVP: localhost, mono-operatore (D12-B). Auth default
none(utentedev@local). - Il backend NON tocca il DB: ogni esecuzione SQL passa da
tht(BE-2). Nessun driverpg/REST nel backend. - Posizione harness: path configurabile (
THT_HARNESS_DIR, default../harnessrispetto al backend);thtinvocato dal venv harness; spawn di Pi concwd = THT_HARNESS_DIR. - Nessun segreto nel codice: credenziali via
.env/ambiente, mai committate. - Test in CI senza Pi/LLM reali: si usa
fake-pi-rpc(Piano Harness,harness/tests/fake_pi/fake_pi_rpc.mjs). L2 con Pi reale resta separato e informativo.
Task 1: Scaffold del progetto backend
Files:
- Create:
backend/package.json,backend/tsconfig.json,backend/vitest.config.ts - Create:
backend/src/config.ts,backend/src/server.ts,backend/src/app.ts - Test:
backend/test/health.test.ts
Interfaces:
-
Produces:
buildApp(config: AppConfig): FastifyInstance(registra le route, non ascolta);loadConfig(env): AppConfigcon{ port, harnessDir, thtBin, piBin, authMode, defaults: {provider?, model?, thinking?}, maxPiProcesses }. -
Step 1: package.json + tsconfig + vitest config
// backend/package.json
{
"name": "thothii-backend",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"start": "node dist/server.js"
},
"dependencies": { "fastify": "^5.0.0" },
"devDependencies": { "typescript": "^5.6.0", "tsx": "^4.19.0", "vitest": "^2.1.0", "@types/node": "^22.0.0" }
}
// backend/tsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler",
"strict": true, "outDir": "dist", "rootDir": "src", "esModuleInterop": true, "skipLibCheck": true },
"include": ["src"] }
// backend/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node", include: ["test/**/*.test.ts"] } });
- Step 2: Scrivere il test health
// backend/test/health.test.ts
import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("GET /health ritorna ok", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});
- Step 3: Eseguire (deve fallire)
Run: cd backend && npm install && npm test
Expected: FAIL — Cannot find module '../src/app.js'.
- Step 4: Implementare config + app + server
// backend/src/config.ts
export interface AppConfig {
port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "oidc";
defaults: { provider?: string; model?: string; thinking?: string };
maxPiProcesses: number;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
return {
port: Number(env.PORT ?? 8787),
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
thtBin: env.THT_BIN ?? "tht",
piBin: env.PI_BIN ?? "pi",
authMode: (env.AUTH_MODE as AppConfig["authMode"]) ?? "none",
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
};
}
// backend/src/app.ts
import Fastify, { type FastifyInstance } from "fastify";
import type { AppConfig } from "./config.js";
export function buildApp(_config: AppConfig): FastifyInstance {
const app = Fastify({ logger: false });
app.get("/health", async () => ({ status: "ok" }));
return app;
}
// backend/src/server.ts
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
const config = loadConfig(process.env);
const app = buildApp(config);
app.listen({ port: config.port, host: "127.0.0.1" })
.then((addr) => console.log(`backend listening on ${addr}`));
- Step 5: Eseguire (deve passare) + commit
Run: cd backend && npm test
Expected: PASS.
git add backend/package.json backend/tsconfig.json backend/vitest.config.ts backend/src backend/test
git commit -m "feat(backend): scaffold Fastify+TS + /health"
Task 2: LineSplitter — lettura JSONL LF-only
Files:
- Create:
backend/src/rpc/line-splitter.ts - Test:
backend/test/line-splitter.test.ts
Interfaces:
-
Produces:
attachJsonlReader(stream: Readable, onLine: (line: string) => void): () => void— split su\n, strip\rfinale, gestione chunk parziali; ritorna funzione di detach. -
Step 1: Scrivere il test
import { test, expect } from "vitest";
import { Readable } from "node:stream";
import { attachJsonlReader } from "../src/rpc/line-splitter.js";
test("riassembla righe spezzate tra chunk, split solo su \\n", async () => {
const lines: string[] = [];
const s = new Readable({ read() {} });
attachJsonlReader(s, (l) => lines.push(l));
s.push('{"a":1}\n{"b":'); s.push('2}\r\n{"u":"
in stringa"}\n'); s.push(null);
await new Promise((r) => s.on("end", r));
expect(lines).toEqual(['{"a":1}', '{"b":2}', '{"u":"
in stringa"}']);
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- line-splitter
Expected: FAIL — modulo assente.
- Step 3: Implementare (port di jsonl.js)
// backend/src/rpc/line-splitter.ts
import { StringDecoder } from "node:string_decoder";
import type { Readable } from "node:stream";
export function attachJsonlReader(stream: Readable, onLine: (line: string) => void): () => void {
const decoder = new StringDecoder("utf8");
let buffer = "";
const emit = (line: string) => onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
const onData = (chunk: Buffer | string) => {
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
for (let nl; (nl = buffer.indexOf("\n")) !== -1; ) {
emit(buffer.slice(0, nl)); buffer = buffer.slice(nl + 1);
}
};
const onEnd = () => { buffer += decoder.end(); if (buffer) { emit(buffer); buffer = ""; } };
stream.on("data", onData); stream.on("end", onEnd);
return () => { stream.off("data", onData); stream.off("end", onEnd); };
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- line-splitter
Expected: PASS.
git add backend/src/rpc/line-splitter.ts backend/test/line-splitter.test.ts
git commit -m "feat(backend): LF-only JSONL line splitter"
Task 3: RpcClient — spawn, invio comandi, correlazione risposte
Files:
- Create:
backend/src/rpc/rpc-client.ts - Test:
backend/test/rpc-client.test.ts
Interfaces:
-
Consumes:
attachJsonlReader(Task 2);harness/tests/fake_pi/fake_pi_rpc.mjs(Piano Harness). -
Produces:
class RpcClient:constructor(child: ChildProcessWithoutNullStreams)send(cmd: object): void— scriveJSON.stringify(cmd)+"\n"su stdinrequest(cmd: object & {type:string}): Promise<any>— invia conidgenerato, risolve sulla{type:"response"}correlataon(event: "event", cb: (evt: any) => void)— emette ogni messaggio NON-response (eventi:extension_ui_request,text_delta,agent_end, …)nextId(): string
-
Step 1: Scrivere il test (contro fake-pi-rpc)
import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { RpcClient } from "../src/rpc/rpc-client.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("request(get_available_models) correla la response", async () => {
const child = spawn("node", [FAKE, SCRIPT]);
const rpc = new RpcClient(child as any);
const res = await rpc.request({ type: "get_available_models" });
expect(res.data.models[0].provider).toBe("zai");
child.stdin.end();
});
test("prompt emette un evento extension_ui_request", async () => {
const child = spawn("node", [FAKE, SCRIPT]);
const rpc = new RpcClient(child as any);
const got = new Promise<any>((resolve) => rpc.on("event", (e) => e.type === "extension_ui_request" && resolve(e)));
rpc.send({ type: "prompt", message: "/nuova-domanda \"x\"" });
const evt = await got;
expect(evt.method).toBe("input"); // shape nativa di Pi
expect(JSON.parse(evt.title).widget).toBe("select"); // descriptor nel title
child.stdin.end();
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- rpc-client
Expected: FAIL — modulo assente.
- Step 3: Implementare RpcClient
// backend/src/rpc/rpc-client.ts
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { attachJsonlReader } from "./line-splitter.js";
type Listener = (evt: any) => void;
export class RpcClient {
private seq = 0;
private pending = new Map<string, (resp: any) => void>();
private listeners = new Set<Listener>();
constructor(private child: ChildProcessWithoutNullStreams) {
attachJsonlReader(child.stdout, (line) => {
if (!line) return;
let msg: any; try { msg = JSON.parse(line); } catch { return; }
if (msg.type === "response" && msg.id && this.pending.has(msg.id)) {
this.pending.get(msg.id)!(msg); this.pending.delete(msg.id); return;
}
for (const l of this.listeners) l(msg);
});
}
nextId(): string { return `c${++this.seq}`; }
send(cmd: object): void { this.child.stdin.write(JSON.stringify(cmd) + "\n"); }
request(cmd: object & { type: string }): Promise<any> {
const id = this.nextId();
return new Promise((resolve) => { this.pending.set(id, resolve); this.send({ ...cmd, id }); });
}
on(_event: "event", cb: Listener): void { this.listeners.add(cb); }
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- rpc-client
Expected: PASS (2 test).
git add backend/src/rpc/rpc-client.ts backend/test/rpc-client.test.ts
git commit -m "feat(backend): RpcClient (spawn/send/request/event) over JSONL"
Task 4: SessionBridge — traduzione widget-descriptor ↔ RPC + widget pendente
Files:
- Create:
backend/src/bridge/session-bridge.ts - Test:
backend/test/session-bridge.test.ts
Interfaces:
-
Consumes:
RpcClient(Task 3). -
Produces:
class SessionBridge:constructor(rpc: RpcClient)onClientEvent(cb: (e: ClientEvent) => void)— emette verso il FE:{type:"ui_request"|"info"|"text_delta"|"system_event", ...}respond(uiResponse: object & {id:string}): void— invia a Pi{type:"extension_ui_response", id, value: JSON.stringify(uiResponse)}(shape NATIVA: il payload va invalue)steer(text: string): void— invia{type:"steer", message: text}pendingWidget(): object | null— l'ultimo descriptor non ancora risposto (per re-emit)
-
Decodifica della shape nativa (WIRE CONTRACT): un evento
{type:"extension_ui_request", id, method:"input", title}→ il bridge faJSON.parse(title)→ descriptor, lo espone comeui_request;method:"notify"→info; altrimethod(setStatus/setWidget) ignorati in MVP. -
Tipi:
ClientEvent = {type:"ui_request", ui_request:object} | {type:"text_delta", text:string} | {type:"info",...} | {type:"system_event",...}. -
Step 1: Scrivere il test
import { test, expect, vi } from "vitest";
import { SessionBridge } from "../src/bridge/session-bridge.js";
function fakeRpc() {
const sent: any[] = []; let evcb: any;
return { rpc: { send: (c:any)=>sent.push(c), on: (_:any,cb:any)=>{evcb=cb}, request: vi.fn() } as any,
sent, fire: (m:any)=>evcb(m) };
}
test("extension_ui_request nativo (method:input, title=json) diventa ui_request ed è il pendente", () => {
const { rpc, fire } = fakeRpc();
const b = new SessionBridge(rpc);
const seen: any[] = []; b.onClientEvent((e)=>seen.push(e));
const descriptor = { id:"u1", widget:"select" };
fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify(descriptor) });
expect(seen[0]).toEqual({ type:"ui_request", ui_request: descriptor });
expect(b.pendingWidget()).toEqual(descriptor);
});
test("respond invia extension_ui_response con payload in value e azzera il pendente", () => {
const { rpc, sent, fire } = fakeRpc();
const b = new SessionBridge(rpc);
fire({ type:"extension_ui_request", id:"u1", method:"input", title: JSON.stringify({ id:"u1", widget:"select" }) });
b.respond({ id:"u1", choices:["a"] });
expect(sent.at(-1)).toEqual({ type:"extension_ui_response", id:"u1", value: JSON.stringify({ id:"u1", choices:["a"] }) });
expect(b.pendingWidget()).toBeNull();
});
test("steer invia un comando steer", () => {
const { rpc, sent } = fakeRpc();
new SessionBridge(rpc).steer("considera solo il 2024");
expect(sent.at(-1)).toEqual({ type:"steer", message:"considera solo il 2024" });
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- session-bridge
Expected: FAIL — modulo assente.
- Step 3: Implementare SessionBridge
// backend/src/bridge/session-bridge.ts
import type { RpcClient } from "../rpc/rpc-client.js";
export type ClientEvent =
| { type: "ui_request"; ui_request: any }
| { type: "text_delta"; text: string }
| { type: "info"; [k: string]: any }
| { type: "system_event"; [k: string]: any };
export class SessionBridge {
private pending: any = null;
private cbs = new Set<(e: ClientEvent) => void>();
constructor(private rpc: RpcClient) {
rpc.on("event", (m) => {
if (m.type === "extension_ui_request" && m.method === "input") {
let descriptor: any; try { descriptor = JSON.parse(m.title); } catch { return; }
this.pending = descriptor;
this.fan({ type: "ui_request", ui_request: descriptor });
} else if (m.type === "extension_ui_request" && m.method === "notify") {
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
} else if (m.type === "text_delta") {
this.fan({ type: "text_delta", text: m.text ?? "" });
} else if (m.type === "system_event") {
this.fan(m as ClientEvent);
}
// altri method nativi (setStatus/setWidget) e altri eventi Pi (agent_end, tool_call) non inoltrati in MVP
});
}
private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); }
onClientEvent(cb: (e: ClientEvent) => void) { this.cbs.add(cb); }
respond(uiResponse: object & { id: string }) {
this.rpc.send({ type: "extension_ui_response", id: uiResponse.id, value: JSON.stringify(uiResponse) });
if (this.pending && uiResponse.id === this.pending.id) this.pending = null;
}
steer(text: string) { this.rpc.send({ type: "steer", message: text }); }
pendingWidget() { return this.pending; }
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- session-bridge
Expected: PASS (3 test).
git add backend/src/bridge/session-bridge.ts backend/test/session-bridge.test.ts
git commit -m "feat(backend): SessionBridge widget-descriptor <-> RPC + pending widget"
Task 5: ThtRunner — wrapper dei comandi tht … --json
Files:
- Create:
backend/src/tht/tht-runner.ts - Test:
backend/test/tht-runner.test.ts
Interfaces:
-
Produces:
class ThtRunner(config:{thtBin, harnessDir, configPath}):sessionNew(opts): Promise<{id:string}>→tht session new <q> [--provider…] --jsonsessionList(): Promise<SessionRow[]>→tht session list --jsonsessionShow(id): Promise<any>→tht session show <id> --jsonsqlPreview(id, {limit, offset}): Promise<{columns,rows,execution_ms,truncated}>sqlExport(id): Promise<{path:string}>run(args: string[]): Promise<{code:number, stdout:string, stderr:string}>(primitiva, iniettabile per test)
-
Step 1: Scrivere il test (run iniettabile)
import { test, expect } from "vitest";
import { ThtRunner } from "../src/tht/tht-runner.js";
test("sessionNew parsa l'id dal JSON", async () => {
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async () => ({ code: 0, stdout: '{"id":"2026-06-27-100000-x"}', stderr: "" });
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});
test("run con exit != 0 propaga errore con stderr", async () => {
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" });
await expect(r.sessionList()).rejects.toThrow(/boom/);
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- tht-runner
Expected: FAIL — modulo assente.
- Step 3: Implementare ThtRunner
// backend/src/tht/tht-runner.ts
import { spawn } from "node:child_process";
export interface ThtConfig { thtBin: string; harnessDir: string; configPath: string; }
export interface SessionRow { id: string; status: string; question: string; summary: string | null;
created_at: string; updated_at: string | null; author: string | null; }
export class ThtRunner {
constructor(private cfg: ThtConfig) {}
run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const ch = spawn(this.cfg.thtBin, ["-c", this.cfg.configPath, ...args], { cwd: this.cfg.harnessDir });
let stdout = "", stderr = "";
ch.stdout.on("data", (d) => (stdout += d)); ch.stderr.on("data", (d) => (stderr += d));
ch.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
});
}
private async json<T>(args: string[]): Promise<T> {
const { code, stdout, stderr } = await this.run(args);
if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`);
return JSON.parse(stdout) as T;
}
async sessionNew(o: { question: string; provider?: string; model?: string; thinking?: string; name?: string }) {
const a = ["session", "new", o.question];
for (const [f, v] of [["--provider", o.provider], ["--model", o.model], ["--thinking", o.thinking], ["--name", o.name]] as const)
if (v) a.push(f, v);
a.push("--json");
return this.json<{ id: string }>(a);
}
sessionList() { return this.json<SessionRow[]>(["session", "list", "--json"]); }
sessionShow(id: string) { return this.json<any>(["session", "show", id, "--json"]); }
sqlPreview(id: string, p: { limit?: number; offset?: number }) {
const a = ["sql", "preview", `sessions/${id}/sql_final.sql`, "--session", id, "--json"];
if (p.limit != null) a.push("--limit", String(p.limit));
if (p.offset) a.push("--offset", String(p.offset));
return this.json<{ columns: string[]; rows: unknown[][]; execution_ms: number; truncated: boolean }>(a);
}
async sqlExport(id: string) {
const { code, stdout, stderr } = await this.run(["sql", "export", "--session", id]);
if (code !== 0) throw new Error(`tht sql export exit ${code}: ${stderr.trim()}`);
return { path: stdout.trim() };
}
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- tht-runner
Expected: PASS (2 test).
git add backend/src/tht/tht-runner.ts backend/test/tht-runner.test.ts
git commit -m "feat(backend): ThtRunner wrapper for tht --json (sessions, sql preview/export)"
Task 6: PiProcessManager — un Pi per sessione, spawn/teardown/resume + settings
Files:
- Create:
backend/src/pi/pi-process-manager.ts - Test:
backend/test/pi-process-manager.test.ts
Interfaces:
-
Consumes:
RpcClient(Task 3),SessionBridge(Task 4),AppConfig(Task 1). -
Produces:
class PiProcessManager:spawnFor(sessionId: string, opts: {provider?, model?, thinking?, name?, author?}): Promise<SessionRuntime>— spawnpi --mode rpcconcwd=harnessDir, env{...process.env, THT_SESSION: sessionId, THT_AUTHOR: author, PATH: <venv/bin>:PATH},--approve; dopo lo spawn applicaset_model/set_thinking_levelvia RPC; mandaprompt "/nuova-domanda …"per avviare il workflow.get(sessionId): SessionRuntime | undefinedteardown(sessionId): voidcount(): number— rispettamaxPiProcesses(errore esplicito oltre il cap)
-
SessionRuntime = { rpc: RpcClient; bridge: SessionBridge; child: ChildProcess } -
Iniettabile:
spawnFn(defaultspawn) per test senza Pi reale. -
Step 1: Scrivere il test (spawnFn iniettato = fake-pi-rpc)
import { test, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { PiProcessManager } from "../src/pi/pi-process-manager.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("spawnFor avvia un runtime e il bridge emette il widget F1", async () => {
const cfg = loadConfig({ THT_HARNESS_DIR: "../harness" });
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
const rt = await mgr.spawnFor("2026-06-27-100000-x", {});
const widget = await new Promise<any>((res) => rt.bridge.onClientEvent((e) => e.type === "ui_request" && res(e)));
expect(widget.ui_request.widget).toBe("select");
mgr.teardown("2026-06-27-100000-x");
expect(mgr.count()).toBe(0);
});
test("oltre maxPiProcesses solleva errore", async () => {
const cfg = { ...loadConfig({}), maxPiProcesses: 1 };
const mgr = new PiProcessManager(cfg, { spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any });
await mgr.spawnFor("a", {});
await expect(mgr.spawnFor("b", {})).rejects.toThrow(/max/i);
mgr.teardown("a");
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- pi-process-manager
Expected: FAIL — modulo assente.
- Step 3: Implementare PiProcessManager
// backend/src/pi/pi-process-manager.ts
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js";
export interface SessionRuntime { rpc: RpcClient; bridge: SessionBridge; child: ChildProcessWithoutNullStreams; }
type SpawnFn = (cfg: AppConfig, sessionId: string, env: NodeJS.ProcessEnv) => ChildProcessWithoutNullStreams;
export class PiProcessManager {
private runtimes = new Map<string, SessionRuntime>();
private spawnFn: SpawnFn;
constructor(private cfg: AppConfig, opts?: { spawnFn?: (...a: any[]) => ChildProcessWithoutNullStreams }) {
this.spawnFn = opts?.spawnFn
? () => opts.spawnFn!()
: (cfg, sessionId, env) => nodeSpawn(cfg.piBin, ["--mode", "rpc", "--approve"], { cwd: cfg.harnessDir, env });
}
count() { return this.runtimes.size; }
get(id: string) { return this.runtimes.get(id); }
async spawnFor(sessionId: string, o: { provider?: string; model?: string; thinking?: string; author?: string }) {
if (this.runtimes.size >= this.cfg.maxPiProcesses) throw new Error("max Pi processes reached");
const env = { ...process.env, THT_SESSION: sessionId, THT_AUTHOR: o.author ?? "dev@local" };
const child = this.spawnFn(this.cfg, sessionId, env);
const rpc = new RpcClient(child); const bridge = new SessionBridge(rpc);
const rt: SessionRuntime = { rpc, bridge, child };
this.runtimes.set(sessionId, rt);
child.on("exit", () => this.runtimes.delete(sessionId));
const provider = o.provider ?? this.cfg.defaults.provider;
const model = o.model ?? this.cfg.defaults.model;
const thinking = o.thinking ?? this.cfg.defaults.thinking;
if (provider && model) await rpc.request({ type: "set_model", provider, modelId: model });
if (thinking) await rpc.request({ type: "set_thinking_level", level: thinking });
rpc.send({ type: "prompt", message: `/nuova-domanda "kickoff"` });
return rt;
}
teardown(id: string) { const rt = this.runtimes.get(id); if (rt) { rt.child.kill(); this.runtimes.delete(id); } }
}
Nota PATH (rischio L2 #1): in produzione
env.PATHdeve includereharness/.venv/binperché Pi spawnitht. Aggiungere alla composizioneenv:PATH: \${harnessVenvBin}:${process.env.PATH}``. Coperto in Task 11 (wiring reale).
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- pi-process-manager
Expected: PASS (2 test).
git add backend/src/pi/pi-process-manager.ts backend/test/pi-process-manager.test.ts
git commit -m "feat(backend): PiProcessManager (one Pi per session, cap, set_model/thinking)"
Task 7: SSE hub + re-emit del widget pendente
Files:
- Create:
backend/src/sse/sse-hub.ts - Test:
backend/test/sse-hub.test.ts
Interfaces:
-
Produces:
class SseHub:subscribe(sessionId, send: (event: string, data: object) => void, pending?: object | null): () => void— alla sottoscrizione, sependingè presente, invia subitosend("ui_request", {ui_request: pending}); ritorna unsubscribe.publish(sessionId, event: string, data: object): void— a tutti i subscriber della sessione.
-
Step 1: Scrivere il test
import { test, expect } from "vitest";
import { SseHub } from "../src/sse/sse-hub.js";
test("re-emette il widget pendente alla sottoscrizione", () => {
const hub = new SseHub(); const sent: any[] = [];
hub.subscribe("s1", (ev, data) => sent.push({ ev, data }), { id: "u1", widget: "select" });
expect(sent[0]).toEqual({ ev: "ui_request", data: { ui_request: { id: "u1", widget: "select" } } });
});
test("publish raggiunge i subscriber e unsubscribe li stacca", () => {
const hub = new SseHub(); const sent: any[] = [];
const off = hub.subscribe("s1", (ev, data) => sent.push({ ev, data }));
hub.publish("s1", "text_delta", { text: "x" });
off(); hub.publish("s1", "text_delta", { text: "y" });
expect(sent).toEqual([{ ev: "text_delta", data: { text: "x" } }]);
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- sse-hub
Expected: FAIL — modulo assente.
- Step 3: Implementare SseHub
// backend/src/sse/sse-hub.ts
type Send = (event: string, data: object) => void;
export class SseHub {
private subs = new Map<string, Set<Send>>();
subscribe(sessionId: string, send: Send, pending?: object | null): () => void {
if (!this.subs.has(sessionId)) this.subs.set(sessionId, new Set());
this.subs.get(sessionId)!.add(send);
if (pending) send("ui_request", { ui_request: pending });
return () => this.subs.get(sessionId)?.delete(send);
}
publish(sessionId: string, event: string, data: object): void {
for (const s of this.subs.get(sessionId) ?? []) s(event, data);
}
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- sse-hub
Expected: PASS (2 test).
git add backend/src/sse/sse-hub.ts backend/test/sse-hub.test.ts
git commit -m "feat(backend): SSE hub with pending-widget re-emit on (re)subscribe"
Task 8: Auth middleware pluggabile (none/mock/oidc)
Files:
- Create:
backend/src/auth/auth.ts - Test:
backend/test/auth.test.ts
Interfaces:
-
Produces:
authPreHandler(mode)→ Fastify preHandler che impostareq.user = {id}:none→dev@local;mock→headerx-mock-user;oidc→verifica bearer (stub MVP:501se non configurato).getUser(req): {id:string}. -
Step 1: Scrivere il test
import { test, expect } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getUser } from "../src/auth/auth.js";
test("mode none assegna dev@local", async () => {
const app = Fastify(); app.addHook("preHandler", authPreHandler("none"));
app.get("/me", async (req) => getUser(req));
expect((await app.inject({ method: "GET", url: "/me" })).json()).toEqual({ id: "dev@local" });
});
test("mode mock legge l'header", async () => {
const app = Fastify(); app.addHook("preHandler", authPreHandler("mock"));
app.get("/me", async (req) => getUser(req));
const res = await app.inject({ method: "GET", url: "/me", headers: { "x-mock-user": "alice" } });
expect(res.json()).toEqual({ id: "alice" });
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- auth
Expected: FAIL — modulo assente.
- Step 3: Implementare auth
// backend/src/auth/auth.ts
import type { FastifyRequest, FastifyReply } from "fastify";
export function authPreHandler(mode: "none" | "mock" | "oidc") {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") (req as any).user = { id: "dev@local" };
else if (mode === "mock") (req as any).user = { id: (req.headers["x-mock-user"] as string) ?? "mock" };
else { reply.code(501); throw new Error("OIDC non configurato (MVP: usa none/mock)"); }
};
}
export function getUser(req: FastifyRequest): { id: string } { return (req as any).user ?? { id: "dev@local" }; }
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- auth
Expected: PASS (2 test).
git add backend/src/auth/auth.ts backend/test/auth.test.ts
git commit -m "feat(backend): pluggable auth (none/mock/oidc seam)"
Task 9: Route sessioni + SSE + response/steer (wiring)
Files:
- Modify:
backend/src/app.ts(registra le route, costruisce i singleton) - Create:
backend/src/routes/sessions.ts - Test:
backend/test/routes-sessions.test.ts
Interfaces:
-
Consumes:
PiProcessManager(6),ThtRunner(5),SseHub(7),auth(8). -
Produces (route, tutte sotto
authPreHandler):POST /sessions {workspace, question, provider?, model?, thinking?, name?}→tht session new(con author dall'auth) →mgr.spawnFor(id)→{id}GET /sessions→tht session list --jsonGET /sessions/:id→tht session show --jsonGET /sessions/:id/events→ SSE; sottoscriveSseHubconbridge.pendingWidget(); collegabridge.onClientEvent→hub.publishPOST /sessions/:id/response {ui_response}→bridge.respondPOST /sessions/:id/steer {text}→bridge.steerPOST /sessions/:id/close→mgr.teardown
-
Step 1: Scrivere il test (inietta ThtRunner + spawnFn fake)
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);
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- routes-sessions
Expected: FAIL — buildApp non accetta deps / route assenti.
- Step 3: Rendere buildApp iniettabile e registrare le route
In app.ts accettare deps?: { thtRunner?, spawnFn? }, costruire ThtRunner/PiProcessManager/SseHub, applicare authPreHandler(config.authMode), e registrare sessionRoutes.
// backend/src/routes/sessions.ts (estratto load-bearing)
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);
});
}
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- routes-sessions
Expected: PASS (2 test).
git add backend/src/app.ts backend/src/routes/sessions.ts backend/test/routes-sessions.test.ts
git commit -m "feat(backend): session routes + SSE + response/steer wiring"
Task 10: Route SQL (preview/export) + models + workspaces
Files:
- Create:
backend/src/routes/sql.ts,backend/src/routes/meta.ts - Modify:
backend/src/app.ts(registra) - Test:
backend/test/routes-sql-meta.test.ts
Interfaces:
-
Produces:
POST /sessions/:id/sql/preview {limit?, offset?}→tht.sqlPreview→{columns, rows, execution_ms, truncated}POST /sessions/:id/sql/export→tht.sqlExport→{path}GET /models→rpc.request({type:"get_available_models"})da un Pi effimero, OR set statico dai default se nessun Pi attivo (MVP: legge da un processo Pi effimero viamgr); ritorna{models:[...]}GET /workspaces→ lista daharnessDir/workspaces/*.yaml(solo nomi + path, no secret)
-
Step 1: Scrivere il test
import { test, expect } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("POST sql/preview ritorna le righe da ThtRunner", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: { sqlPreview: async () => ({ columns: ["a"], rows: [[1]], execution_ms: 2, truncated: false }) } as any,
});
const res = await app.inject({ method: "POST", url: "/sessions/s1/sql/preview", payload: { limit: 10, offset: 0 } });
expect(res.json()).toEqual({ columns: ["a"], rows: [[1]], execution_ms: 2, truncated: false });
});
test("GET /workspaces elenca gli yaml senza secret", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any });
const res = await app.inject({ method: "GET", url: "/workspaces" });
expect(res.statusCode).toBe(200);
expect(Array.isArray(res.json())).toBe(true);
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- routes-sql-meta
Expected: FAIL — route assenti.
- Step 3: Implementare le route
// backend/src/routes/sql.ts
import type { FastifyInstance } from "fastify";
import type { ThtRunner } from "../tht/tht-runner.js";
export function sqlRoutes(app: FastifyInstance, d: { tht: ThtRunner }) {
app.post("/sessions/:id/sql/preview", async (req) => {
const b = (req.body ?? {}) as any;
return d.tht.sqlPreview((req.params as any).id, { limit: b.limit, offset: b.offset });
});
app.post("/sessions/:id/sql/export", async (req) => d.tht.sqlExport((req.params as any).id));
}
// backend/src/routes/meta.ts
import type { FastifyInstance } from "fastify";
import { readdirSync } from "node:fs";
import { join } from "node:path";
export function metaRoutes(app: FastifyInstance, d: { harnessDir: string; listModels: () => Promise<any> }) {
app.get("/workspaces", async () => {
const dir = join(d.harnessDir, "workspaces");
return readdirSync(dir).filter((f) => f.endsWith(".yaml"))
.map((f) => ({ name: f.replace(/\.ya?ml$/, ""), file: f }));
});
app.get("/models", async () => d.listModels());
}
In app.ts, listModels fa spawn di un Pi effimero, rpc.request({type:"get_available_models"}), poi teardown; in caso di errore ritorna { models: [] }.
- Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test -- routes-sql-meta
Expected: PASS (2 test).
git add backend/src/routes/sql.ts backend/src/routes/meta.ts backend/src/app.ts backend/test/routes-sql-meta.test.ts
git commit -m "feat(backend): sql preview/export + models + workspaces routes"
Task 11: Resume + PATH venv + smoke end-to-end F1 (fake-pi-rpc)
Files:
- Modify:
backend/src/pi/pi-process-manager.ts(env PATH venv;resume(sessionId)legge il manifest via ThtRunner) - Modify:
backend/src/routes/sessions.ts(POST /sessions/:id/resume) - Test:
backend/test/e2e-f1.test.ts
Interfaces:
-
Produces:
mgr.resume(sessionId, tht)— rileggeprovider/model/thinkingdatht.sessionShowe faspawnForcon quei valori; PATH dello spawn reale includeharnessDir/.venv/bin. -
Step 1: Scrivere lo smoke end-to-end
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("loop F1: crea sessione → SSE riceve il widget → risponde → 204", 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.listen({ port: 0, host: "127.0.0.1" });
const base = `http://127.0.0.1:${(app.server.address() as any).port}`;
await fetch(`${base}/sessions`, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ workspace: "w", question: "q" }) });
// SSE: leggi il primo evento ui_request
const es = await fetch(`${base}/sessions/s1/events`);
const reader = es.body!.getReader(); const chunk = await reader.read();
const text = new TextDecoder().decode(chunk.value);
expect(text).toContain("ui_request");
await reader.cancel();
const resp = await fetch(`${base}/sessions/s1/response`, { method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ui_response: { id: "u1", choices: ["a"] } }) });
expect(resp.status).toBe(204);
await app.close();
});
- Step 2: Eseguire (deve fallire)
Run: cd backend && npm test -- e2e-f1
Expected: FAIL inizialmente (race sull'ordine widget/SSE o resume assente). Diagnosticare con systematic-debugging se necessario.
-
Step 3: Implementare resume + PATH venv + fix ordine eventi
-
In
pi-process-manager.ts(spawn reale):PATH: \${join(cfg.harnessDir, ".venv/bin")}:${process.env.PATH}``. -
Aggiungere
async resume(id, tht)che leggesessionShow(id)→{provider, model, thinking}e chiamaspawnFor. -
Garantire che la route
POST /sessionscolleghibridge.onClientEvent → hub.publishPRIMA di mandare ilprompt, così l'evento widget non si perde; ilpendingWidget()+ re-emit (Task 7) copre comunque il caso SSE sottoscritto dopo. -
Aggiungere
POST /sessions/:id/resume→mgr.resume(id, tht)+ ricollegamento bridge→hub. -
Step 4: Eseguire (deve passare) + commit
Run: cd backend && npm test
Expected: PASS (tutta la suite).
git add backend/src/pi/pi-process-manager.ts backend/src/routes/sessions.ts backend/test/e2e-f1.test.ts
git commit -m "feat(backend): resume + venv PATH + end-to-end F1 smoke (fake-pi-rpc)"
- Step 5: Validazione L2 con Pi reale (informativo, non-CI)
Con harness configurato (.env + VPN) e config/tht.yaml valido: avviare npm run dev, fare POST /sessions con una domanda reale, aprire l'SSE e confermare che il widget F1 arriva dal Pi reale e che la risposta avanza il workflow. Annotare l'esito (questo è il primo loop end-to-end reale BE↔harness).
Self-Review
Spec coverage (vs 2026-06-27-backend-design.md):
- BE-1 (un Pi per sessione, resume): Task 6 + Task 11 ✓
- BE-2 (delega SQL): Task 5 (ThtRunner) + Task 10 (route) ✓
- BE-3 (re-emit widget pendente): Task 7 + Task 4 (
pendingWidget) ✓ - BE-4 (test fake-Pi + unit TS): tutti i task usano vitest; fake-pi-rpc in Task 3/6/9/11 ✓
- BE-5 (id pre-creato): Task 9 (
tht session newpoispawnForconTHT_SESSION) ✓ (dipende dal Piano Harness Task 5) - BE-6 (model/thinking/provider per-sessione, persistiti, resume): Task 6 (
set_model/set_thinking) + Task 11 (resume legge manifest) ✓ - BE-7 (settings Pi MVP):
--name/provider/model/thinking viasessionNew(Task 5) +GET /models(Task 10);--approvenello spawn (Task 6);quietStartup/trust/systemPromptsono harness-side (Piano Harness Task 9) ✓ - API §4 (tutti gli endpoint): Task 9 (sessions/events/response/steer/close) + Task 10 (sql/models/workspaces) + Task 1 (health) ✓
- Auth D6: Task 8 ✓
- Componenti §5 (PiProcessManager, RpcClient, SessionBridge, SseHub, ThtRunner, auth, config): Task 1–9 ✓
- Rischio PATH
tht(§9): Task 6 (nota) + Task 11 (fix) ✓
Placeholder scan: nessun TBD. Le note implementative (PATH venv, listModels effimero) sono concretizzate nel task che le richiede (11, 10).
Type consistency: ThtRunner.sqlPreview ritorna {columns, rows, execution_ms, truncated} usato identico in Task 10. SessionRuntime = {rpc, bridge, child} coerente tra Task 6 e Task 9. SessionBridge.respond(uiResponse)/steer(text)/pendingWidget() coerenti tra Task 4, 7, 9. Comandi RPC (set_model {provider, modelId}, set_thinking_level {level}, steer {message}, prompt {message}, get_available_models) coerenti con rpc-types.d.ts di Pi. Envelope {type:"extension_ui_request", ui_request} coerente con il gate (Piano Harness Task 4) e il fake-pi-rpc (Piano Harness Task 10).
Nota di sequenza: Task 1–8 sono indipendenti dal Pi reale (CI puro con fake-pi-rpc). Le validazioni L2 (Task 11 Step 5) richiedono harness configurato + VPN.