diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 1a2ae8c0..d78292c3 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -7,7 +7,11 @@ 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 }); + // NOTE (MVP): `workspace` selects the tht `-c ` for the backend's own + // tht calls (session new/list/show). The gate/Pi side still uses the symlinked + // `config/tht.yaml` (single-workspace). Full multi-workspace on the Pi side is + // future work — the gate would need a THT_CONFIG/workspace env passed at spawn. + const { id } = await d.tht.sessionNew({ question: b.question, provider: b.provider, model: b.model, thinking: b.thinking, name: b.name, workspace: b.workspace }); 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 }; diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 65a83d7b..04fba387 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; export interface ThtConfig { thtBin: string; @@ -19,9 +21,21 @@ export interface SessionRow { export class ThtRunner { constructor(private cfg: ThtConfig) {} - run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + /** + * Resolve the `-c ` args. If `workspace` is given AND a matching + * `workspaces/.yaml` exists under harnessDir, select it; otherwise + * fall back to the default configPath. + */ + private configArg(workspace?: string): string[] { + if (workspace && existsSync(join(this.cfg.harnessDir, "workspaces", `${workspace}.yaml`))) { + return ["-c", `workspaces/${workspace}.yaml`]; + } + return ["-c", this.cfg.configPath]; + } + + run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { - const ch = spawn(this.cfg.thtBin, ["-c", this.cfg.configPath, ...args], { + const ch = spawn(this.cfg.thtBin, [...this.configArg(workspace), ...args], { cwd: this.cfg.harnessDir, }); let stdout = ""; @@ -32,8 +46,8 @@ export class ThtRunner { }); } - private async json(args: string[]): Promise { - const { code, stdout, stderr } = await this.run(args); + private async json(args: string[], workspace?: string): Promise { + const { code, stdout, stderr } = await this.run(args, workspace); if (code !== 0) throw new Error(`tht ${args.join(" ")} exit ${code}: ${stderr.trim()}`); return JSON.parse(stdout) as T; } @@ -44,6 +58,7 @@ export class ThtRunner { model?: string; thinking?: string; name?: string; + workspace?: string; }) { const a = ["session", "new", o.question]; for (const [f, v] of [ @@ -55,7 +70,7 @@ export class ThtRunner { if (v) a.push(f, v); } a.push("--json"); - return this.json<{ id: string }>(a); + return this.json<{ id: string }>(a, o.workspace); } sessionList() { diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 8cf285e9..f3e36e58 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -7,13 +7,19 @@ 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 () => { +test("POST /sessions crea e avvia, GET /sessions lista; inoltra workspace a sessionNew", async () => { + let sessionNewArg: any; const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { - thtRunner: { sessionNew: async () => ({ id: "s1" }), sessionList: async () => [{ id: "s1" }] } as any, + thtRunner: { + sessionNew: async (o: any) => { sessionNewArg = o; return { 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" }); + expect(sessionNewArg.workspace).toBe("w"); + expect(sessionNewArg.question).toBe("q"); const list = await app.inject({ method: "GET", url: "/sessions" }); expect(list.json()).toEqual([{ id: "s1" }]); }); diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts index 283dd0f7..ef28cbf3 100644 --- a/backend/test/tht-runner.test.ts +++ b/backend/test/tht-runner.test.ts @@ -1,6 +1,23 @@ -import { test, expect } from "vitest"; +import { test, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; import { ThtRunner } from "../src/tht/tht-runner.js"; +// Spy on child_process.spawn so we can capture the resolved argv (incl. -c config) +// that ThtRunner.run() builds, without launching a real process. +vi.mock("node:child_process", () => ({ + spawn: vi.fn(() => { + const ch: any = new EventEmitter(); + ch.stdout = new EventEmitter(); + ch.stderr = new EventEmitter(); + queueMicrotask(() => { + ch.stdout.emit("data", Buffer.from('{"id":"x"}')); + ch.emit("close", 0); + }); + return ch; + }), +})); +import { spawn } from "node:child_process"; + 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: "" }); @@ -12,3 +29,13 @@ test("run with exit != 0 propagates error with stderr", async () => { r.run = async () => ({ code: 1, stdout: "", stderr: "ERRORE: boom" }); await expect(r.sessionList()).rejects.toThrow(/boom/); }); + +test("sessionNew with missing workspace file falls back to default configPath argv", async () => { + // harnessDir "/nope" has no workspaces/foo.yaml -> configArg falls back to default. + (spawn as any).mockClear(); + const r = new ThtRunner({ thtBin: "tht", harnessDir: "/nope", configPath: "config/tht.yaml" }); + await r.sessionNew({ question: "q", workspace: "foo" }); + const [bin, argv] = (spawn as any).mock.calls[0]; + expect(bin).toBe("tht"); + expect(argv).toEqual(["-c", "config/tht.yaml", "session", "new", "q", "--json"]); +});