From bfbb017413071ed4542476bd324e9b55a9d10968 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 16:09:04 +0200 Subject: [PATCH] feat(backend): /settings GET+PUT, /models PiModel shape, app wiring Co-Authored-By: Claude Opus 4.8 --- backend/src/app.ts | 13 ++++- backend/src/routes/meta.ts | 33 +++--------- backend/src/routes/settings.ts | 48 +++++++++++++++++ backend/test/routes-settings.test.ts | 78 ++++++++++++++++++++++++++++ backend/test/routes-sql-meta.test.ts | 14 +++-- 5 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 backend/src/routes/settings.ts create mode 100644 backend/test/routes-settings.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 3e5a5dc1..a12de7b9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,11 +8,16 @@ import { authPreHandler } from "./auth/auth.js"; import { sessionRoutes } from "./routes/sessions.js"; import { sqlRoutes } from "./routes/sql.js"; import { metaRoutes, type ListModelsFn } from "./routes/meta.js"; +import { settingsRoutes } from "./routes/settings.js"; +import { createPiModelLister } from "./pi/list-models.js"; +import { loadSettings, type Settings } from "./settings/settings-store.js"; +import { effectiveSettings } from "./routes/settings.js"; export interface BuildAppDeps { thtRunner?: ThtRunner; spawnFn?: () => any; listModels?: ListModelsFn; + getSettings?: () => Settings; } export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { @@ -29,11 +34,15 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc const mgr = new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined); const hub = new SseHub(); + const listModels = deps?.listModels ?? createPiModelLister(config); + const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config))); + app.addHook("preHandler", authPreHandler(config.authMode)); app.get("/health", async () => ({ status: "ok" })); - sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub }); + sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings }); sqlRoutes(app, { tht: tht as ThtRunner }); - metaRoutes(app, { harnessDir: config.harnessDir, listModels: deps?.listModels }); + metaRoutes(app, { harnessDir: config.harnessDir, listModels }); + settingsRoutes(app, { cfg: config, listModels }); return app; } diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index be8dec79..bfb86be5 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -1,14 +1,15 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import type { FastifyInstance } from "fastify"; +import type { PiModel } from "../pi/list-models.js"; -export type ListModelsFn = () => Promise; +export type ListModelsFn = () => Promise; /** * List YAML workspace configs found in /workspaces/*.yaml. * Returns [{name, file}] — no database credentials or secrets. */ -function listWorkspaces(harnessDir: string): { name: string; file: string }[] { +export function listWorkspaces(harnessDir: string): { name: string; file: string }[] { const dir = join(harnessDir, "workspaces"); let entries: string[]; try { @@ -18,10 +19,7 @@ function listWorkspaces(harnessDir: string): { name: string; file: string }[] { } return entries .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) - .map((f) => ({ - name: f.replace(/\.ya?ml$/, ""), - file: f, - })); + .map((f) => ({ name: f.replace(/\.ya?ml$/, ""), file: f })); } export function metaRoutes( @@ -32,28 +30,13 @@ export function metaRoutes( return listWorkspaces(deps.harnessDir); }); - app.get("/models", async (_req, reply) => { - const fn = deps.listModels ?? defaultListModels; + app.get("/models", async () => { + const fn = deps.listModels ?? (async () => []); try { - const models = await fn(); - return { models }; + return { models: await fn() }; } catch { // Graceful fallback: Pi may not be running; don't crash the server. - return { models: [] }; + return { models: [] as PiModel[] }; } }); } - -/** - * Default implementation: spawns a short-lived `tht` invocation that asks a Pi - * process for available models via get_available_models. This is intentionally - * behind the injectable seam so tests can stub it without spawning real processes. - * - * In the MVP we return an empty list — the real spawn path can be wired in later - * once a Pi-side "list models" RPC stabilises. - */ -async function defaultListModels(): Promise { - // Real ephemeral Pi spawn left for a follow-up task. - // Returning [] here triggers the graceful fallback seen by clients. - return []; -} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 00000000..b21b6989 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,48 @@ +import type { FastifyInstance } from "fastify"; +import type { AppConfig } from "../config.js"; +import { loadSettings, saveSettings, type Settings } from "../settings/settings-store.js"; +import { listWorkspaces, type ListModelsFn } from "./meta.js"; + +/** Merge stored settings over env/first-workspace defaults. */ +export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings { + const workspaces = listWorkspaces(cfg.harnessDir); + return { + workspace: stored.workspace ?? (workspaces[0]?.name), + provider: stored.provider ?? cfg.defaults.provider, + model: stored.model ?? cfg.defaults.model, + thinking: stored.thinking ?? cfg.defaults.thinking, + }; +} + +export function settingsRoutes( + app: FastifyInstance, + deps: { cfg: AppConfig; listModels: ListModelsFn }, +): void { + app.get("/settings", async () => { + return effectiveSettings(deps.cfg, loadSettings(deps.cfg)); + }); + + app.put("/settings", async (req, reply) => { + const b = (req.body ?? {}) as Settings; + if (b.model) { + let available: { id: string }[] = []; + try { + available = await deps.listModels(); + } catch { + available = []; + } + // Only validate when Pi gave us a non-empty list; otherwise allow (degraded). + if (available.length > 0 && !available.some((m) => m.id === b.model)) { + return reply.code(400).send({ error: `Unknown model: ${b.model}` }); + } + } + const next: Settings = { + workspace: b.workspace, + provider: b.provider, + model: b.model, + thinking: b.thinking, + }; + saveSettings(deps.cfg, next); + return effectiveSettings(deps.cfg, next); + }); +} diff --git a/backend/test/routes-settings.test.ts b/backend/test/routes-settings.test.ts new file mode 100644 index 00000000..2103dd9c --- /dev/null +++ b/backend/test/routes-settings.test.ts @@ -0,0 +1,78 @@ +import { test, expect } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +function appWithTmpSettings(extraEnv: Record = {}, deps = {}) { + const dir = mkdtempSync(join(tmpdir(), "tht-set-route-")); + const app = buildApp( + loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: join(dir, "settings.json"), ...extraEnv }), + { thtRunner: {} as any, ...deps }, + ); + return { app, dir }; +} + +test("GET /settings returns effective defaults (env provider/model/thinking, first workspace)", async () => { + const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const res = await app.inject({ method: "GET", url: "/settings" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.provider).toBe("zai"); + expect(body.model).toBe("glm-5.2"); + expect(body.thinking).toBe("medium"); + expect(typeof body.workspace).toBe("string"); // first workspace from ../harness/workspaces + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings persists and GET reads it back", async () => { + const { app, dir } = appWithTmpSettings({}, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }, + }); + expect(put.statusCode).toBe(200); + const got = await app.inject({ method: "GET", url: "/settings" }); + expect(got.json()).toMatchObject({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings rejects an unknown model when a model list is available", async () => { + const { app, dir } = appWithTmpSettings({}, { + listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", provider: "zai", model: "does-not-exist", thinking: "low" }, + }); + expect(put.statusCode).toBe(400); + expect(put.json()).toMatchObject({ error: expect.stringMatching(/model/i) }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("PUT /settings allows any model when model list is empty (Pi unavailable)", async () => { + const { app, dir } = appWithTmpSettings({}, { listModels: async () => [] }); + try { + const put = await app.inject({ + method: "PUT", url: "/settings", + payload: { workspace: "psd", model: "whatever", thinking: "low" }, + }); + expect(put.statusCode).toBe(200); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/backend/test/routes-sql-meta.test.ts b/backend/test/routes-sql-meta.test.ts index 5b6ed253..e4b2252e 100644 --- a/backend/test/routes-sql-meta.test.ts +++ b/backend/test/routes-sql-meta.test.ts @@ -123,13 +123,17 @@ test("GET /workspaces returns [] when harnessDir has no workspaces subdir", asyn test("GET /models returns {models:[...]} from injected listModels stub", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, - listModels: async () => ["claude-opus-4", "claude-sonnet-4-5"], + listModels: async () => [ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, + ], }); const res = await app.inject({ method: "GET", url: "/models" }); expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ models: ["claude-opus-4", "claude-sonnet-4-5"] }); + expect(res.json()).toEqual({ + models: [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }], + }); }); test("GET /models returns {models:[]} when listModels throws (graceful fallback)", async () => { @@ -144,8 +148,8 @@ test("GET /models returns {models:[]} when listModels throws (graceful fallback) expect(res.json()).toEqual({ models: [] }); }); -test("GET /models with no listModels injected falls back to {models:[]}", async () => { - // No listModels dep → defaultListModels → returns [] → {models:[]} +test("GET /models with no listModels injected uses real lister (returns array)", async () => { + // No listModels dep → createPiModelLister (real); returns PiModel[] (possibly empty if Pi not running) const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: {} as any, // listModels intentionally omitted @@ -154,5 +158,5 @@ test("GET /models with no listModels injected falls back to {models:[]}", async const res = await app.inject({ method: "GET", url: "/models" }); expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ models: [] }); + expect(Array.isArray(res.json().models)).toBe(true); });