Files
ThothII/docs/superpowers/plans/2026-06-27-backend-implementation.md
T
marcopanandClaude Opus 4.8 aecc86f328 docs(plans): correggi wire contract post-spike (ctx.ui.input nativo, niente sendRaw)
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>
2026-06-27 19:47:21 +02:00

1025 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_SESSION` id 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 `\r` finale. MAI `readline`. 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 **decodifica `title` → descriptor**, lo espone al FE come `ui_request`, e risponde `{type:"extension_ui_response", id, value:"<ui_response JSON>"}` (o `{id, cancelled:true}`). `ctx.sendRaw` non 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à del `SessionBridge`.
- **Deployment MVP:** localhost, mono-operatore (D12-B). Auth default `none` (utente `dev@local`).
- **Il backend NON tocca il DB**: ogni esecuzione SQL passa da `tht` (BE-2). Nessun driver `pg`/REST nel backend.
- **Posizione harness:** path configurabile (`THT_HARNESS_DIR`, default `../harness` rispetto al backend); `tht` invocato dal venv harness; spawn di Pi con `cwd = 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): AppConfig` con `{ port, harnessDir, thtBin, piBin, authMode, defaults: {provider?, model?, thinking?}, maxPiProcesses }`.
- [ ] **Step 1: package.json + tsconfig + vitest config**
```json
// 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" }
}
```
```json
// backend/tsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "Bundler",
"strict": true, "outDir": "dist", "rootDir": "src", "esModuleInterop": true, "skipLibCheck": true },
"include": ["src"] }
```
```typescript
// 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**
```typescript
// 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**
```typescript
// 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),
};
}
```
```typescript
// 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;
}
```
```typescript
// 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.
```bash
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 `\r` finale, gestione chunk parziali; ritorna funzione di detach.
- [ ] **Step 1: Scrivere il test**
```typescript
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)**
```typescript
// 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.
```bash
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` — scrive `JSON.stringify(cmd)+"\n"` su stdin
- `request(cmd: object & {type:string}): Promise<any>` — invia con `id` generato, risolve sulla `{type:"response"}` correlata
- `on(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)**
```typescript
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**
```typescript
// 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).
```bash
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 in `value`)
- `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 fa `JSON.parse(title)` → descriptor, lo espone come `ui_request`; `method:"notify"` → `info`; altri `method` (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**
```typescript
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**
```typescript
// 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).
```bash
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…] --json`
- `sessionList(): Promise<SessionRow[]>` → `tht session list --json`
- `sessionShow(id): Promise<any>` → `tht session show <id> --json`
- `sqlPreview(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)**
```typescript
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**
```typescript
// 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).
```bash
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>` — spawn `pi --mode rpc` con `cwd=harnessDir`, env `{...process.env, THT_SESSION: sessionId, THT_AUTHOR: author, PATH: <venv/bin>:PATH}`, `--approve`; dopo lo spawn applica `set_model`/`set_thinking_level` via RPC; manda `prompt "/nuova-domanda …"` per avviare il workflow.
- `get(sessionId): SessionRuntime | undefined`
- `teardown(sessionId): void`
- `count(): number` — rispetta `maxPiProcesses` (errore esplicito oltre il cap)
- `SessionRuntime = { rpc: RpcClient; bridge: SessionBridge; child: ChildProcess }`
- Iniettabile: `spawnFn` (default `spawn`) per test senza Pi reale.
- [ ] **Step 1: Scrivere il test (spawnFn iniettato = fake-pi-rpc)**
```typescript
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**
```typescript
// 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.PATH` deve includere `harness/.venv/bin` perché Pi spawni `tht`. Aggiungere alla composizione `env`: `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).
```bash
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, se `pending` è presente, invia subito `send("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**
```typescript
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**
```typescript
// 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).
```bash
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 imposta `req.user = {id}`: `none`→`dev@local`; `mock`→header `x-mock-user`; `oidc`→verifica bearer (stub MVP: `501` se non configurato). `getUser(req): {id:string}`.
- [ ] **Step 1: Scrivere il test**
```typescript
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**
```typescript
// 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).
```bash
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 --json`
- `GET /sessions/:id` → `tht session show --json`
- `GET /sessions/:id/events` → SSE; sottoscrive `SseHub` con `bridge.pendingWidget()`; collega `bridge.onClientEvent` → `hub.publish`
- `POST /sessions/:id/response {ui_response}` → `bridge.respond`
- `POST /sessions/:id/steer {text}` → `bridge.steer`
- `POST /sessions/:id/close` → `mgr.teardown`
- [ ] **Step 1: Scrivere il test (inietta ThtRunner + spawnFn fake)**
```typescript
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`.
```typescript
// 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).
```bash
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 via `mgr`); ritorna `{models:[...]}`
- `GET /workspaces` → lista da `harnessDir/workspaces/*.yaml` (solo nomi + path, no secret)
- [ ] **Step 1: Scrivere il test**
```typescript
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**
```typescript
// 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));
}
```
```typescript
// 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).
```bash
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)` — rilegge `provider/model/thinking` da `tht.sessionShow` e fa `spawnFor` con quei valori; PATH dello spawn reale include `harnessDir/.venv/bin`.
- [ ] **Step 1: Scrivere lo smoke end-to-end**
```typescript
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 legge `sessionShow(id)` → `{provider, model, thinking}` e chiama `spawnFor`.
- Garantire che la route `POST /sessions` colleghi `bridge.onClientEvent → hub.publish` PRIMA di mandare il `prompt`, così l'evento widget non si perde; il `pendingWidget()` + 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).
```bash
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 new` poi `spawnFor` con `THT_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 via `sessionNew` (Task 5) + `GET /models` (Task 10); `--approve` nello spawn (Task 6); `quietStartup`/`trust`/`systemPrompt` sono 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.