feat(backend): support container runtime paths

This commit is contained in:
2026-07-11 21:32:33 +02:00
parent a0daa319ca
commit c6c00c336a
9 changed files with 144 additions and 38 deletions
+1
View File
@@ -34,6 +34,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: "config/tht.yaml",
dataRoot: config.dataRoot,
});
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = new SseHub();
+4 -1
View File
@@ -1,13 +1,15 @@
export interface AppConfig {
port: number; harnessDir: string; thtBin: string; piBin: string;
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "oidc";
defaults: { provider?: string; model?: string; thinking?: string };
maxPiProcesses: number;
settingsFile: string;
dataRoot?: string;
ollamaEnsureTimeoutMs: number;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
return {
host: env.HOST ?? "127.0.0.1",
port: Number(env.PORT ?? 8787),
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
thtBin: env.THT_BIN ?? "tht",
@@ -16,6 +18,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
};
}
+9 -12
View File
@@ -1,5 +1,4 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { join } from "node:path";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
@@ -11,7 +10,11 @@ export interface PiModel {
}
interface Opts {
spawnFn?: () => ChildProcessWithoutNullStreams;
spawnFn?: (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
ttlMs?: number;
nowMs?: () => number;
}
@@ -24,22 +27,16 @@ interface Opts {
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
const ttlMs = opts.ttlMs ?? 60_000;
const now = opts.nowMs ?? (() => Date.now());
const spawnFn =
opts.spawnFn ??
(() => {
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
return nodeSpawn(cfg.piBin, ["--mode", "rpc"], {
cwd: cfg.harnessDir,
env: { ...process.env, PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}` },
}) as ChildProcessWithoutNullStreams;
});
const spawnFn = opts.spawnFn ?? nodeSpawn;
let cache: { at: number; models: PiModel[] } | null = null;
return async function listModels(): Promise<PiModel[]> {
if (cache && now() - cache.at < ttlMs) return cache.models;
const child = spawnFn();
const env: NodeJS.ProcessEnv = { ...process.env };
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env });
child.stderr.resume();
const rpc = new RpcClient(child);
try {
+25 -23
View File
@@ -1,5 +1,4 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { join } from "node:path";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js";
@@ -11,8 +10,12 @@ export interface SessionRuntime {
child: ChildProcessWithoutNullStreams;
}
/** Injected test double signature: produce a child process, no args needed. */
type SpawnFn = () => ChildProcessWithoutNullStreams;
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
type SpawnFn = (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
export class PiProcessManager {
private runtimes = new Map<string, SessionRuntime>();
@@ -20,30 +23,29 @@ export class PiProcessManager {
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
if (opts?.spawnFn) {
this.spawnFn = () => opts.spawnFn!();
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(opts.spawnFn!, sessionId, author);
} else {
this.spawnFn = (sessionId: string, author: string) => {
const harnessVenvBin = join(cfg.harnessDir, ".venv", "bin");
const env: NodeJS.ProcessEnv = {
...process.env,
THT_SESSION: sessionId,
THT_AUTHOR: author,
PATH: `${harnessVenvBin}:${process.env.PATH ?? ""}`,
};
// pi 0.73 (the @mariozechner rebrand) removed the `--approve` flag: rpc mode is
// headless and runs tools without an approval gate, so passing it makes pi exit
// with "Unknown option: --approve". Args are intentionally just `--mode rpc`.
const child = nodeSpawn(cfg.piBin, ["--mode", "rpc"], {
cwd: cfg.harnessDir,
env,
});
// Drain stderr so the child's stderr buffer never blocks the process.
child.stderr.resume();
return child;
};
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(nodeSpawn, sessionId, author);
}
}
private spawnPi(spawnFn: SpawnFn, sessionId: string, author: string): ChildProcessWithoutNullStreams {
const env: NodeJS.ProcessEnv = {
...process.env,
THT_SESSION: sessionId,
THT_AUTHOR: author,
};
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
const child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
cwd: this.cfg.harnessDir,
env,
});
// Drain stderr so the child's stderr buffer never blocks the process.
child.stderr.resume?.();
return child;
}
count(): number { return this.runtimes.size; }
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
+1 -1
View File
@@ -2,5 +2,5 @@ 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" })
app.listen({ port: config.port, host: config.host })
.then((addr) => console.log(`backend listening on ${addr}`));
+4
View File
@@ -6,6 +6,7 @@ export interface ThtConfig {
thtBin: string;
harnessDir: string;
configPath: string;
dataRoot?: string;
}
export interface SessionRow {
@@ -61,8 +62,11 @@ export class ThtRunner {
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const env: NodeJS.ProcessEnv = { ...process.env };
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
cwd: this.cfg.harnessDir,
env,
});
let stdout = "";
let stderr = "";