feat: expose safe pi management api
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import { execFile as nodeExecFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
type Settings,
|
||||
} from "../settings/settings-store.js";
|
||||
import type { PiModel } from "./list-models.js";
|
||||
|
||||
const execFile = promisify(nodeExecFile);
|
||||
const REASONING_CHOICES = ["low", "medium", "high"] as const;
|
||||
const VERSION_PATTERN = /^(?:pi(?:\s+version)?\s+)?v?(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)$/;
|
||||
const MAX_LOG_LINES = 200;
|
||||
const MAX_LOG_LINE_LENGTH = 4_096;
|
||||
const MAX_EXEC_OUTPUT_BYTES = 64 * 1024;
|
||||
|
||||
export type PiReasoning = typeof REASONING_CHOICES[number];
|
||||
|
||||
export interface PiInstallationConfig {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
reasoning?: PiReasoning;
|
||||
}
|
||||
|
||||
export interface PiStatus {
|
||||
version?: string;
|
||||
ready: boolean;
|
||||
config: PiInstallationConfig;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiOptions {
|
||||
providers: string[];
|
||||
models: Array<{ provider: string; id: string }>;
|
||||
reasoning: PiReasoning[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiTestResult {
|
||||
ready: boolean;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiLogs {
|
||||
lines: string[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiExecFileOptions {
|
||||
timeout: number;
|
||||
maxBuffer: number;
|
||||
}
|
||||
|
||||
export type PiExecFile = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: PiExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
export interface PiManagementService {
|
||||
status(): Promise<PiStatus>;
|
||||
options(): Promise<PiOptions>;
|
||||
configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }>;
|
||||
test(): Promise<PiTestResult>;
|
||||
logs(): Promise<PiLogs>;
|
||||
}
|
||||
|
||||
export class PiManagementError extends Error {
|
||||
constructor(
|
||||
public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface PiManagementDeps {
|
||||
execute?: PiExecFile;
|
||||
listModels: () => Promise<PiModel[]>;
|
||||
readSettings?: () => Settings;
|
||||
saveSettings?: (settings: Settings) => Settings;
|
||||
readLogs?: () => string | Promise<string>;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const diagnostics: string[] = [];
|
||||
const addDiagnostic = (message: string): void => {
|
||||
diagnostics.push(`${now().toISOString()} ${redact(message)}`);
|
||||
if (diagnostics.length > MAX_LOG_LINES) diagnostics.splice(0, diagnostics.length - MAX_LOG_LINES);
|
||||
};
|
||||
const execute = deps.execute ?? defaultExecFile;
|
||||
const readSettings = deps.readSettings ?? (() => loadSettings(config));
|
||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||
|
||||
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
|
||||
let listed: PiModel[];
|
||||
try {
|
||||
listed = await deps.listModels();
|
||||
} catch {
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable");
|
||||
}
|
||||
const models: Array<{ provider: string; id: string }> = [];
|
||||
const providers: string[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
const seenProviders = new Set<string>();
|
||||
for (const model of listed) {
|
||||
if (!isChoice(model?.provider) || !isChoice(model?.id)) continue;
|
||||
const key = `${model.provider}\u0000${model.id}`;
|
||||
if (seenModels.has(key)) continue;
|
||||
seenModels.add(key);
|
||||
models.push({ provider: model.provider, id: model.id });
|
||||
if (!seenProviders.has(model.provider)) {
|
||||
seenProviders.add(model.provider);
|
||||
providers.push(model.provider);
|
||||
}
|
||||
}
|
||||
return { providers, models, reasoning: [...REASONING_CHOICES] };
|
||||
};
|
||||
|
||||
const version = async (): Promise<string> => {
|
||||
let output: { stdout: string; stderr: string };
|
||||
try {
|
||||
// The Pi executable and every argument are installation-owned constants. Do not add a shell.
|
||||
output = await execute(config.piBin, ["--version"], {
|
||||
timeout: config.piManagementTimeoutMs,
|
||||
maxBuffer: MAX_EXEC_OUTPUT_BYTES,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeout(error)) {
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi smoke check timed out");
|
||||
}
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi runtime is unavailable");
|
||||
}
|
||||
const matched = VERSION_PATTERN.exec(output.stdout.trim());
|
||||
if (!matched) throw new PiManagementError("pi_management_unavailable", "Pi runtime returned an invalid version");
|
||||
return matched[1];
|
||||
};
|
||||
|
||||
const installationConfig = (): PiInstallationConfig => {
|
||||
const settings = readSettings();
|
||||
const provider = config.defaults.provider ?? settings.provider;
|
||||
const model = config.defaults.model ?? settings.model;
|
||||
const reasoning = config.defaults.thinking ?? settings.thinking;
|
||||
return {
|
||||
...(isChoice(provider) ? { provider } : {}),
|
||||
...(isChoice(model) ? { model } : {}),
|
||||
...(isReasoning(reasoning) ? { reasoning } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async status(): Promise<PiStatus> {
|
||||
const checkedAt = now().toISOString();
|
||||
const current = installationConfig();
|
||||
try {
|
||||
const currentVersion = await version();
|
||||
addDiagnostic("Pi version probe succeeded");
|
||||
return { version: currentVersion, ready: true, config: current, checkedAt };
|
||||
} catch (error) {
|
||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||
addDiagnostic(message);
|
||||
return { ready: false, config: current, checkedAt, message };
|
||||
}
|
||||
},
|
||||
|
||||
async options(): Promise<PiOptions> {
|
||||
const choices = await closedOptions();
|
||||
return { ...choices, checkedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }> {
|
||||
if (!isInstallationConfig(value)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid");
|
||||
}
|
||||
const choices = await closedOptions();
|
||||
if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices");
|
||||
}
|
||||
try {
|
||||
persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning });
|
||||
} catch {
|
||||
throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved");
|
||||
}
|
||||
addDiagnostic("Pi installation defaults updated");
|
||||
return { ...value, updatedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async test(): Promise<PiTestResult> {
|
||||
const checkedAt = now().toISOString();
|
||||
try {
|
||||
await version();
|
||||
const current = installationConfig();
|
||||
if (!current.provider || !current.model || !current.reasoning) {
|
||||
return smokeFailure("Pi installation configuration is incomplete", checkedAt, addDiagnostic);
|
||||
}
|
||||
const choices = await closedOptions();
|
||||
if (!choices.models.some((model) => model.provider === current.provider && model.id === current.model)) {
|
||||
return smokeFailure("Configured Pi provider and model are unavailable", checkedAt, addDiagnostic);
|
||||
}
|
||||
addDiagnostic("Pi smoke check succeeded");
|
||||
return { ready: true, checkedAt };
|
||||
} catch (error) {
|
||||
return smokeFailure(stableMessage(error, "Pi smoke check failed"), checkedAt, addDiagnostic);
|
||||
}
|
||||
},
|
||||
|
||||
async logs(): Promise<PiLogs> {
|
||||
let source = "";
|
||||
try {
|
||||
source = await readLogs();
|
||||
} catch {
|
||||
source = "Pi diagnostics are unavailable";
|
||||
}
|
||||
const lines = source
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-MAX_LOG_LINES)
|
||||
.map((line) => redact(line.slice(0, MAX_LOG_LINE_LENGTH)));
|
||||
return { lines, checkedAt: now().toISOString() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultExecFile(command: string, args: string[], options: PiExecFileOptions) {
|
||||
const result = await execFile(command, args, {
|
||||
timeout: options.timeout,
|
||||
maxBuffer: options.maxBuffer,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
||||
}
|
||||
|
||||
function isChoice(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value
|
||||
&& /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value);
|
||||
}
|
||||
|
||||
function isReasoning(value: unknown): value is PiReasoning {
|
||||
return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isInstallationConfig(value: unknown): value is Required<PiInstallationConfig> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) {
|
||||
return false;
|
||||
}
|
||||
return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning);
|
||||
}
|
||||
|
||||
function isTimeout(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error && typeof error === "object" && (
|
||||
(error as { code?: unknown }).code === "ETIMEDOUT"
|
||||
|| (error as { killed?: unknown }).killed === true
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stableMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof PiManagementError ? error.message : fallback;
|
||||
}
|
||||
|
||||
function smokeFailure(
|
||||
message: string,
|
||||
checkedAt: string,
|
||||
addDiagnostic: (message: string) => void,
|
||||
): PiTestResult {
|
||||
addDiagnostic(message);
|
||||
return { ready: false, message, checkedAt };
|
||||
}
|
||||
|
||||
export function redact(value: string): string {
|
||||
return value
|
||||
.replace(/(\bauthorization\b\s*:\s*Bearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
||||
.replace(/(\b(?:api[_-]?key|token|password|secret|authorization)\b\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/giu, "$1[REDACTED]")
|
||||
.replace(/(\bBearer\s+)[^\s,;]+/giu, "$1[REDACTED]")
|
||||
.replace(/(\w+:\/\/[^:/\s]+:)[^@/\s]+@/gu, "$1[REDACTED]@");
|
||||
}
|
||||
Reference in New Issue
Block a user