feat(backend): support container runtime paths
This commit is contained in:
@@ -34,6 +34,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
thtBin: config.thtBin,
|
thtBin: config.thtBin,
|
||||||
harnessDir: config.harnessDir,
|
harnessDir: config.harnessDir,
|
||||||
configPath: "config/tht.yaml",
|
configPath: "config/tht.yaml",
|
||||||
|
dataRoot: config.dataRoot,
|
||||||
});
|
});
|
||||||
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||||
const hub = new SseHub();
|
const hub = new SseHub();
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
export interface AppConfig {
|
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";
|
authMode: "none" | "mock" | "oidc";
|
||||||
defaults: { provider?: string; model?: string; thinking?: string };
|
defaults: { provider?: string; model?: string; thinking?: string };
|
||||||
maxPiProcesses: number;
|
maxPiProcesses: number;
|
||||||
settingsFile: string;
|
settingsFile: string;
|
||||||
|
dataRoot?: string;
|
||||||
ollamaEnsureTimeoutMs: number;
|
ollamaEnsureTimeoutMs: number;
|
||||||
}
|
}
|
||||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||||
return {
|
return {
|
||||||
|
host: env.HOST ?? "127.0.0.1",
|
||||||
port: Number(env.PORT ?? 8787),
|
port: Number(env.PORT ?? 8787),
|
||||||
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
|
||||||
thtBin: env.THT_BIN ?? "tht",
|
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 },
|
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
||||||
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
||||||
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
|
||||||
|
dataRoot: env.THT_DATA_ROOT,
|
||||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { RpcClient } from "../rpc/rpc-client.js";
|
import { RpcClient } from "../rpc/rpc-client.js";
|
||||||
|
|
||||||
@@ -11,7 +10,11 @@ export interface PiModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Opts {
|
interface Opts {
|
||||||
spawnFn?: () => ChildProcessWithoutNullStreams;
|
spawnFn?: (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
) => ChildProcessWithoutNullStreams;
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
nowMs?: () => number;
|
nowMs?: () => number;
|
||||||
}
|
}
|
||||||
@@ -24,22 +27,16 @@ interface Opts {
|
|||||||
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
||||||
const ttlMs = opts.ttlMs ?? 60_000;
|
const ttlMs = opts.ttlMs ?? 60_000;
|
||||||
const now = opts.nowMs ?? (() => Date.now());
|
const now = opts.nowMs ?? (() => Date.now());
|
||||||
const spawnFn =
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
let cache: { at: number; models: PiModel[] } | null = null;
|
let cache: { at: number; models: PiModel[] } | null = null;
|
||||||
|
|
||||||
return async function listModels(): Promise<PiModel[]> {
|
return async function listModels(): Promise<PiModel[]> {
|
||||||
if (cache && now() - cache.at < ttlMs) return cache.models;
|
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();
|
child.stderr.resume();
|
||||||
const rpc = new RpcClient(child);
|
const rpc = new RpcClient(child);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { join } from "node:path";
|
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { RpcClient } from "../rpc/rpc-client.js";
|
import { RpcClient } from "../rpc/rpc-client.js";
|
||||||
import { SessionBridge } from "../bridge/session-bridge.js";
|
import { SessionBridge } from "../bridge/session-bridge.js";
|
||||||
@@ -11,8 +10,12 @@ export interface SessionRuntime {
|
|||||||
child: ChildProcessWithoutNullStreams;
|
child: ChildProcessWithoutNullStreams;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Injected test double signature: produce a child process, no args needed. */
|
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
|
||||||
type SpawnFn = () => ChildProcessWithoutNullStreams;
|
type SpawnFn = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||||
|
) => ChildProcessWithoutNullStreams;
|
||||||
|
|
||||||
export class PiProcessManager {
|
export class PiProcessManager {
|
||||||
private runtimes = new Map<string, SessionRuntime>();
|
private runtimes = new Map<string, SessionRuntime>();
|
||||||
@@ -20,30 +23,29 @@ export class PiProcessManager {
|
|||||||
|
|
||||||
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
|
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
|
||||||
if (opts?.spawnFn) {
|
if (opts?.spawnFn) {
|
||||||
this.spawnFn = () => opts.spawnFn!();
|
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(opts.spawnFn!, sessionId, author);
|
||||||
} else {
|
} else {
|
||||||
this.spawnFn = (sessionId: string, author: string) => {
|
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(nodeSpawn, sessionId, author);
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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; }
|
count(): number { return this.runtimes.size; }
|
||||||
|
|
||||||
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
|
get(id: string): SessionRuntime | undefined { return this.runtimes.get(id); }
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { buildApp } from "./app.js";
|
|||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
const config = loadConfig(process.env);
|
const config = loadConfig(process.env);
|
||||||
const app = buildApp(config);
|
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}`));
|
.then((addr) => console.log(`backend listening on ${addr}`));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface ThtConfig {
|
|||||||
thtBin: string;
|
thtBin: string;
|
||||||
harnessDir: string;
|
harnessDir: string;
|
||||||
configPath: string;
|
configPath: string;
|
||||||
|
dataRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRow {
|
export interface SessionRow {
|
||||||
@@ -61,8 +62,11 @@ export class ThtRunner {
|
|||||||
|
|
||||||
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
run(args: string[], workspace?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
return new Promise((resolve) => {
|
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), {
|
const ch = spawn(this.cfg.thtBin, this.buildArgv(args, workspace), {
|
||||||
cwd: this.cfg.harnessDir,
|
cwd: this.cfg.harnessDir,
|
||||||
|
env,
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
test("loadConfig accepts container listening and runtime paths", () => {
|
||||||
|
expect(loadConfig({
|
||||||
|
HOST: "0.0.0.0",
|
||||||
|
PORT: "9000",
|
||||||
|
THT_HARNESS_DIR: "/app/harness",
|
||||||
|
THT_BIN: "/opt/venv/bin/tht",
|
||||||
|
PI_BIN: "/usr/local/bin/pi",
|
||||||
|
SETTINGS_FILE: "/data/settings/settings.json",
|
||||||
|
THT_DATA_ROOT: "/data",
|
||||||
|
})).toMatchObject({
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 9000,
|
||||||
|
harnessDir: "/app/harness",
|
||||||
|
thtBin: "/opt/venv/bin/tht",
|
||||||
|
piBin: "/usr/local/bin/pi",
|
||||||
|
settingsFile: "/data/settings/settings.json",
|
||||||
|
dataRoot: "/data",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadConfig keeps local development defaults", () => {
|
||||||
|
expect(loadConfig({})).toMatchObject({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8787,
|
||||||
|
harnessDir: "../harness",
|
||||||
|
thtBin: "tht",
|
||||||
|
piBin: "pi",
|
||||||
|
settingsFile: "data/settings.json",
|
||||||
|
});
|
||||||
|
expect(loadConfig({}).dataRoot).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -51,3 +51,33 @@ test("createPiModelLister caches within ttl (spawns once for two calls)", async
|
|||||||
rmSync(path.dirname(script), { recursive: true, force: true });
|
rmSync(path.dirname(script), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production model-list spawn preserves PATH and passes the portable data root", async () => {
|
||||||
|
const script = scriptWith([]);
|
||||||
|
const calls: any[][] = [];
|
||||||
|
const previousPath = process.env.PATH;
|
||||||
|
process.env.PATH = "/usr/local/bin:/usr/bin";
|
||||||
|
try {
|
||||||
|
const lister = createPiModelLister(loadConfig({
|
||||||
|
THT_HARNESS_DIR: "/app/harness",
|
||||||
|
PI_BIN: "/usr/local/bin/pi",
|
||||||
|
THT_DATA_ROOT: "/data",
|
||||||
|
}), {
|
||||||
|
spawnFn: (...args: any[]) => {
|
||||||
|
calls.push(args);
|
||||||
|
return spawn("node", [FAKE, script]) as any;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await lister();
|
||||||
|
expect(calls[0][0]).toBe("/usr/local/bin/pi");
|
||||||
|
expect(calls[0][1]).toEqual(["--mode", "rpc"]);
|
||||||
|
expect(calls[0][2]).toMatchObject({
|
||||||
|
cwd: "/app/harness",
|
||||||
|
env: expect.objectContaining({ PATH: "/usr/local/bin:/usr/bin", THT_DATA_ROOT: "/data" }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousPath === undefined) delete process.env.PATH;
|
||||||
|
else process.env.PATH = previousPath;
|
||||||
|
rmSync(path.dirname(script), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect, vi } from "vitest";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -125,3 +125,38 @@ test("spawnFor default (new) mode sends /nuova-domanda", async () => {
|
|||||||
expect(child._writes.join("")).toContain("/nuova-domanda");
|
expect(child._writes.join("")).toContain("/nuova-domanda");
|
||||||
mgr.teardown("sid-10");
|
mgr.teardown("sid-10");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production spawn uses explicit Pi path and passes portable data root without rewriting PATH", async () => {
|
||||||
|
vi.stubEnv("PATH", "/usr/local/bin:/usr/bin");
|
||||||
|
vi.stubEnv("PI_PROVIDER_API_KEY", "provider-secret");
|
||||||
|
vi.stubEnv("NODE_EXTRA_CA_CERTS", "/certs/company-ca.pem");
|
||||||
|
const calls: any[][] = [];
|
||||||
|
const child = recordingChild();
|
||||||
|
child.stderr.resume = () => {};
|
||||||
|
const spawnFn = (...args: any[]) => { calls.push(args); return child as any; };
|
||||||
|
const cfg = loadConfig({
|
||||||
|
THT_HARNESS_DIR: "/app/harness",
|
||||||
|
PI_BIN: "/usr/local/bin/pi",
|
||||||
|
THT_DATA_ROOT: "/data",
|
||||||
|
});
|
||||||
|
const mgr = new PiProcessManager(cfg, { spawnFn });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mgr.spawnFor("portable-session", { author: "user@example.test" });
|
||||||
|
const [bin, args, options] = calls[0];
|
||||||
|
expect(bin).toBe("/usr/local/bin/pi");
|
||||||
|
expect(args).toEqual(["--mode", "rpc"]);
|
||||||
|
expect(options.cwd).toBe("/app/harness");
|
||||||
|
expect(options.env).toMatchObject({
|
||||||
|
PATH: "/usr/local/bin:/usr/bin",
|
||||||
|
PI_PROVIDER_API_KEY: "provider-secret",
|
||||||
|
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
|
||||||
|
THT_DATA_ROOT: "/data",
|
||||||
|
THT_SESSION: "portable-session",
|
||||||
|
THT_AUTHOR: "user@example.test",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mgr.teardown("portable-session");
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user