fix: harden pi maintenance lifecycle
This commit is contained in:
@@ -15,6 +15,7 @@ import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||||
import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
import { createMaintenanceGate } from "./runtime/maintenance-gate.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||
@@ -32,6 +33,8 @@ export interface BuildAppDeps {
|
||||
workspaceRegistry?: WorkspaceRegistry;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
/** Returns true while a host maintenance transaction is preventing new runtimes. */
|
||||
maintenanceGate?: () => boolean;
|
||||
}
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
@@ -99,6 +102,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||
workspaceRuntimeSupport,
|
||||
maintenanceGate: deps?.maintenanceGate ?? createMaintenanceGate(config.maintenanceFile),
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AppConfig {
|
||||
defaults: { provider?: string; model?: string; thinking?: string };
|
||||
maxPiProcesses: number;
|
||||
settingsFile: string;
|
||||
maintenanceFile: string;
|
||||
dataRoot?: string;
|
||||
ollamaEnsureTimeoutMs: number;
|
||||
secretsFile?: string;
|
||||
@@ -206,6 +207,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",
|
||||
maintenanceFile: env.THT_MAINTENANCE_FILE ?? "data/maintenance.json",
|
||||
dataRoot: env.THT_DATA_ROOT,
|
||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||
secretsFile,
|
||||
|
||||
@@ -37,6 +37,8 @@ export function sessionRoutes(
|
||||
legacyWorkspaceMode?: boolean;
|
||||
/** Fail-closed installation/runtime transport capability check. */
|
||||
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
|
||||
/** Host-controlled admission guard. Existing runtimes deliberately continue. */
|
||||
maintenanceGate: () => boolean;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
@@ -75,6 +77,11 @@ export function sessionRoutes(
|
||||
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
||||
};
|
||||
|
||||
const maintenanceReply = (reply: any) => reply.code(503).send({
|
||||
code: "maintenance",
|
||||
error: "Session admission is temporarily paused for maintenance. Try again shortly.",
|
||||
});
|
||||
|
||||
/** Include retained historical descriptors so removed workspaces remain resumable. */
|
||||
const sessionRevisions = async () => {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
@@ -272,6 +279,7 @@ export function sessionRoutes(
|
||||
});
|
||||
|
||||
app.post("/sessions", async (req, reply) => {
|
||||
if (d.maintenanceGate()) return maintenanceReply(reply);
|
||||
const b = req.body as {
|
||||
question: string; name?: string; workspace?: string; workspaceId?: string;
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
@@ -502,6 +510,7 @@ export function sessionRoutes(
|
||||
return reply.code(204).send();
|
||||
});
|
||||
app.post("/sessions/:id/resume", async (req, reply) => {
|
||||
if (d.maintenanceGate()) return maintenanceReply(reply);
|
||||
const id = (req.params as any).id;
|
||||
const principal = getPrincipal(req);
|
||||
return withSessionLifecycle(id, async () => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* A host-side lifecycle transaction creates this marker before it checks for active sessions.
|
||||
* The marker is intentionally only an admission gate: it must never terminate existing Pi
|
||||
* processes or make their in-flight work unavailable.
|
||||
*/
|
||||
export function createMaintenanceGate(markerFile: string): () => boolean {
|
||||
return () => existsSync(markerFile);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* Core-side, non-interactive installation-default writer used only through compose exec.
|
||||
* It accepts no credentials and writes the same SETTINGS_FILE consumed by session creation. */
|
||||
import { loadConfig } from "../config.js";
|
||||
import { loadSettings, saveSettings, type Settings } from "./settings-store.js";
|
||||
|
||||
const choice = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
|
||||
function value(args: string[], flag: string): string {
|
||||
const at = args.indexOf(flag);
|
||||
if (at < 0 || at + 1 >= args.length || args.filter((part) => part === flag).length !== 1) {
|
||||
throw new Error(`missing ${flag}`);
|
||||
}
|
||||
return args[at + 1];
|
||||
}
|
||||
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 6) throw new Error("only provider, model, and thinking may be configured");
|
||||
const provider = value(args, "--provider");
|
||||
const model = value(args, "--model");
|
||||
const thinking = value(args, "--thinking");
|
||||
if (!choice.test(provider) || !choice.test(model)) throw new Error("invalid provider or model");
|
||||
if (!["low", "medium", "high"].includes(thinking)) throw new Error("invalid thinking level");
|
||||
const cfg = loadConfig(process.env);
|
||||
const next: Settings = { ...loadSettings(cfg), provider, model, thinking };
|
||||
saveSettings(cfg, next);
|
||||
} catch (error) {
|
||||
process.stderr.write(`settings-cli: ${error instanceof Error ? error.message : "invalid configuration"}\n`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { AppConfig } from "../config.js";
|
||||
|
||||
@@ -29,6 +29,21 @@ export function loadSettings(cfg: AppConfig): Settings {
|
||||
/** Persist settings (pretty JSON). Creates the parent directory if needed. */
|
||||
export function saveSettings(cfg: AppConfig, s: Settings): Settings {
|
||||
mkdirSync(dirname(cfg.settingsFile), { recursive: true });
|
||||
writeFileSync(cfg.settingsFile, JSON.stringify(s, null, 2) + "\n", "utf8");
|
||||
const directory = dirname(cfg.settingsFile);
|
||||
const temporary = `${cfg.settingsFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
const fd = openSync(temporary, "wx", 0o600);
|
||||
try {
|
||||
writeFileSync(fd, JSON.stringify(s, null, 2) + "\n", "utf8");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
renameSync(temporary, cfg.settingsFile);
|
||||
// The core image runs Linux. Keep the directory acknowledgement explicit there; Windows
|
||||
// filesystem replacement semantics are delegated to the host-side Go durable writer.
|
||||
if (process.platform !== "win32") {
|
||||
const dirFd = openSync(directory, "r");
|
||||
try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user