# 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:""}`; il backend **decodifica `title` → descriptor**, lo espone al FE come `ui_request`, e risponde `{type:"extension_ui_response", id, value:""}` (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): 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` — 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((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 void>(); private listeners = new Set(); 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 { 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 [--provider…] --json` - `sessionList(): Promise` → `tht session list --json` - `sessionShow(id): Promise` → `tht session show --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(args: string[]): Promise { 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(["session", "list", "--json"]); } sessionShow(id: string) { return this.json(["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` — spawn `pi --mode rpc` con `cwd=harnessDir`, env `{...process.env, THT_SESSION: sessionId, THT_AUTHOR: author, PATH: :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((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(); 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>(); 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 }) { 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.