openclaw/src/process/supervisor/registry.ts

155 lines
3.9 KiB
TypeScript

import type { RunRecord, RunState, TerminationReason } from "./types.js";
function nowMs() {
return Date.now();
}
const DEFAULT_MAX_EXITED_RECORDS = 2_000;
function resolveMaxExitedRecords(value?: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
return DEFAULT_MAX_EXITED_RECORDS;
}
return Math.max(1, Math.floor(value));
}
export type RunRegistry = {
add: (record: RunRecord) => void;
get: (runId: string) => RunRecord | undefined;
list: () => RunRecord[];
listByScope: (scopeKey: string) => RunRecord[];
updateState: (
runId: string,
state: RunState,
patch?: Partial<Pick<RunRecord, "pid" | "terminationReason" | "exitCode" | "exitSignal">>,
) => RunRecord | undefined;
touchOutput: (runId: string) => void;
finalize: (
runId: string,
exit: {
reason: TerminationReason;
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
},
) => { record: RunRecord; firstFinalize: boolean } | null;
delete: (runId: string) => void;
};
export function createRunRegistry(options?: { maxExitedRecords?: number }): RunRegistry {
const records = new Map<string, RunRecord>();
const maxExitedRecords = resolveMaxExitedRecords(options?.maxExitedRecords);
const pruneExitedRecords = () => {
if (!records.size) {
return;
}
let exited = 0;
for (const record of records.values()) {
if (record.state === "exited") {
exited += 1;
}
}
if (exited <= maxExitedRecords) {
return;
}
let remove = exited - maxExitedRecords;
for (const [runId, record] of records.entries()) {
if (remove <= 0) {
break;
}
if (record.state !== "exited") {
continue;
}
records.delete(runId);
remove -= 1;
}
};
const add: RunRegistry["add"] = (record) => {
records.set(record.runId, { ...record });
};
const get: RunRegistry["get"] = (runId) => {
const record = records.get(runId);
return record ? { ...record } : undefined;
};
const list: RunRegistry["list"] = () => {
return Array.from(records.values()).map((record) => ({ ...record }));
};
const listByScope: RunRegistry["listByScope"] = (scopeKey) => {
if (!scopeKey.trim()) {
return [];
}
return Array.from(records.values())
.filter((record) => record.scopeKey === scopeKey)
.map((record) => ({ ...record }));
};
const updateState: RunRegistry["updateState"] = (runId, state, patch) => {
const current = records.get(runId);
if (!current) {
return undefined;
}
const updatedAtMs = nowMs();
const next: RunRecord = {
...current,
...patch,
state,
updatedAtMs,
lastOutputAtMs: current.lastOutputAtMs,
};
records.set(runId, next);
return { ...next };
};
const touchOutput: RunRegistry["touchOutput"] = (runId) => {
const current = records.get(runId);
if (!current) {
return;
}
const ts = nowMs();
records.set(runId, {
...current,
lastOutputAtMs: ts,
updatedAtMs: ts,
});
};
const finalize: RunRegistry["finalize"] = (runId, exit) => {
const current = records.get(runId);
if (!current) {
return null;
}
const firstFinalize = current.state !== "exited";
const ts = nowMs();
const next: RunRecord = {
...current,
state: "exited",
terminationReason: current.terminationReason ?? exit.reason,
exitCode: current.exitCode !== undefined ? current.exitCode : exit.exitCode,
exitSignal: current.exitSignal !== undefined ? current.exitSignal : exit.exitSignal,
updatedAtMs: ts,
};
records.set(runId, next);
pruneExitedRecords();
return { record: { ...next }, firstFinalize };
};
const del: RunRegistry["delete"] = (runId) => {
records.delete(runId);
};
return {
add,
get,
list,
listByScope,
updateState,
touchOutput,
finalize,
delete: del,
};
}