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>
1025 lines
46 KiB
Markdown
1025 lines
46 KiB
Markdown
# 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.
|