From c680060bd950c476a5545ccca9fb1e641b944d5f Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 27 Jun 2026 20:59:58 +0200 Subject: [PATCH] feat(backend): ThtRunner wrapper for tht --json (sessions, sql preview/export) Co-Authored-By: Claude Sonnet 4.6 --- backend/src/tht/tht-runner.ts | 86 +++++++++++++++++++++++++++++++++ backend/test/tht-runner.test.ts | 14 ++++++ 2 files changed, 100 insertions(+) create mode 100644 backend/src/tht/tht-runner.ts create mode 100644 backend/test/tht-runner.test.ts diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts new file mode 100644 index 00000000..65a83d7b --- /dev/null +++ b/backend/src/tht/tht-runner.ts @@ -0,0 +1,86 @@ +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 = ""; + let stderr = ""; + ch.stdout.on("data", (d: Buffer) => (stdout += d)); + ch.stderr.on("data", (d: Buffer) => (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 != null) 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() }; + } +} diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts new file mode 100644 index 00000000..283dd0f7 --- /dev/null +++ b/backend/test/tht-runner.test.ts @@ -0,0 +1,14 @@ +import { test, expect } from "vitest"; +import { ThtRunner } from "../src/tht/tht-runner.js"; + +test("sessionNew parses id from 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 with exit != 0 propagates error with 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/); +});