221 lines
9.6 KiB
JavaScript
221 lines
9.6 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const pathModule = require("path");
|
|
|
|
const SCHEMA_ID = "voidbot.swarm_operator_view.v1";
|
|
const RECORD_KEY = "voidbot-swarm";
|
|
|
|
function projectSwarmOperatorView({ heartbeat, orchestrator, pause, control, observedAt = new Date().toISOString(), sources = {}, route = {} }) {
|
|
const initiativeClock = number(heartbeat?.initiativeClock);
|
|
const participants = array(heartbeat?.participants).map((participant) => {
|
|
const identityId = text(participant?.identityId) || "unknown";
|
|
const pendingMentionCount = array(heartbeat?.pendingMentions)
|
|
.filter((mention) => text(mention?.identityId) === identityId).length;
|
|
return {
|
|
identityId,
|
|
displayName: text(participant?.displayName) || identityId,
|
|
repoName: text(participant?.repoName) || "unknown",
|
|
status: text(participant?.status) || "unknown",
|
|
activeJobId: text(participant?.activeJobId),
|
|
nextTurnInMinutes: number(participant?.nextTurnInMinutes) ?? subtract(number(participant?.nextTurnAt), initiativeClock),
|
|
heat: number(participant?.heat),
|
|
avatarUri: text(participant?.avatarUri) || text(participant?.avatarUrl),
|
|
memoryCount: number(participant?.memoryCount) ?? array(participant?.memories).length,
|
|
pressureCount: array(participant?.responsePressureEvidence).length,
|
|
description: text(participant?.description),
|
|
statePath: text(participant?.statePath) || text(participant?.faceStatePath),
|
|
statePreview: participant?.statePreview ?? null,
|
|
pendingMentionCount,
|
|
};
|
|
});
|
|
const organs = Object.entries(object(orchestrator?.organs)).map(([id, organ]) => ({
|
|
id,
|
|
label: text(organ?.label) || id,
|
|
status: text(organ?.lastStatus) || "unknown",
|
|
lastFinishedAt: text(organ?.lastFinishedAt),
|
|
durationSeconds: number(organ?.lastDurationSeconds),
|
|
}));
|
|
const activeTurnCount = participants.filter((participant) => participant.activeJobId).length;
|
|
const readyNowCount = participants.filter((participant) =>
|
|
participant.status === "active" && !participant.activeJobId && participant.nextTurnInMinutes !== null && participant.nextTurnInMinutes <= 0,
|
|
).length;
|
|
const paused = pause?.paused === true;
|
|
const heartbeatReadable = sources.heartbeat?.readable !== false;
|
|
const orchestratorReadable = sources.orchestrator?.readable !== false;
|
|
|
|
const nextTurn = selectNextTurn(participants);
|
|
const selected = participants.find((participant) => participant.identityId === nextTurn?.identityId) ?? participants[0] ?? null;
|
|
const surfaceOrgan = object(orchestrator?.organs)["voidbot-swarm-surface"];
|
|
const cultMeshServer = object(surfaceOrgan?.cultMeshServer);
|
|
const alerts = buildAlerts({ sources, organs });
|
|
return {
|
|
version: SCHEMA_ID,
|
|
observedAt,
|
|
summary: {
|
|
state: deriveSwarmState({ heartbeatReadable, paused, activeTurnCount, organs }),
|
|
paused,
|
|
participantCount: participants.length,
|
|
activeTurnCount,
|
|
readyNowCount,
|
|
pendingMentionCount: array(heartbeat?.pendingMentions).length,
|
|
nextIdentityId: nextTurn?.identityId ?? null,
|
|
nextDisplayName: nextTurn?.displayName ?? null,
|
|
lastTickAt: text(heartbeat?.lastTickAt),
|
|
},
|
|
route: {
|
|
storeWriteStatus: text(route.storeWriteStatus) || "ok",
|
|
providerStatus: text(route.providerStatus) || "unknown",
|
|
odinStatus: text(route.odinStatus) || text(cultMeshServer.status) || deriveOrchestratorState(organs, orchestratorReadable),
|
|
endpoint: text(route.endpoint) || text(cultMeshServer.bind),
|
|
lastAnnouncementAt: text(route.lastAnnouncementAt) || text(surfaceOrgan?.lastFinishedAt),
|
|
alerts,
|
|
},
|
|
queue: [...participants]
|
|
.sort((left, right) => nullableOrder(left.nextTurnInMinutes, right.nextTurnInMinutes))
|
|
.map((participant, slot) => ({
|
|
slot: slot + 1,
|
|
identityId: participant.identityId,
|
|
displayName: participant.displayName,
|
|
repoName: participant.repoName,
|
|
state: participant.activeJobId ? "active" : participant.status,
|
|
nextTurnInMinutes: participant.nextTurnInMinutes,
|
|
heat: participant.heat ?? number(control?.globalHeat) ?? number(heartbeat?.globalHeat),
|
|
label: participant.displayName,
|
|
status: participant.activeJobId ? "active" : participant.status,
|
|
detail: `${participant.repoName} · ${formatTurnDistance(participant.nextTurnInMinutes)}`,
|
|
badges: compact([
|
|
participant.heat === null ? null : `heat ${participant.heat}`,
|
|
participant.pendingMentionCount > 0 ? `${participant.pendingMentionCount} mention${participant.pendingMentionCount === 1 ? "" : "s"}` : null,
|
|
]),
|
|
})),
|
|
selectedFace: selected ? {
|
|
identityId: selected.identityId,
|
|
displayName: selected.displayName,
|
|
repoName: selected.repoName,
|
|
avatarUri: selected.avatarUri,
|
|
status: selected.status,
|
|
nextTurnInMinutes: selected.nextTurnInMinutes,
|
|
heat: selected.heat ?? number(control?.globalHeat) ?? number(heartbeat?.globalHeat),
|
|
memoryCount: selected.memoryCount,
|
|
pressureCount: selected.pressureCount,
|
|
description: selected.description,
|
|
statePath: selected.statePath,
|
|
statePreview: selected.statePreview,
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
async function readSwarmOperatorInputs(paths) {
|
|
const entries = await Promise.all(Object.entries(paths).map(async ([key, path]) => {
|
|
try {
|
|
const [raw, stats] = await Promise.all([
|
|
fs.promises.readFile(path, "utf8"),
|
|
fs.promises.stat(path),
|
|
]);
|
|
return [key, { value: JSON.parse(raw), status: { readable: true, updatedAt: stats.mtime.toISOString() } }];
|
|
} catch (error) {
|
|
return [key, { value: null, status: { readable: false, error: error instanceof Error ? error.message : String(error) } }];
|
|
}
|
|
}));
|
|
const read = Object.fromEntries(entries);
|
|
return {
|
|
heartbeat: read.heartbeat.value,
|
|
orchestrator: read.orchestrator.value,
|
|
pause: read.pause.value,
|
|
control: read.control?.value ?? null,
|
|
sources: Object.fromEntries(Object.entries(read).map(([key, result]) => [key, result.status])),
|
|
};
|
|
}
|
|
|
|
function watchSwarmOperatorInputs(paths, onChange, { debounceMs = 75 } = {}) {
|
|
const watched = [...new Set(Object.values(paths).map(pathModule.resolve))];
|
|
const byDirectory = new Map();
|
|
for (const path of watched) {
|
|
const directory = pathModule.dirname(path);
|
|
const names = byDirectory.get(directory) ?? new Set();
|
|
names.add(pathModule.basename(path).toLowerCase());
|
|
byDirectory.set(directory, names);
|
|
}
|
|
let timer = null;
|
|
const schedule = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
Promise.resolve(onChange()).catch(() => {});
|
|
}, debounceMs);
|
|
timer.unref?.();
|
|
};
|
|
const watchers = [...byDirectory].map(([directory, names]) => fs.watch(directory, { persistent: false }, (_event, filename) => {
|
|
if (filename === null || names.has(String(filename).toLowerCase())) schedule();
|
|
}));
|
|
return () => {
|
|
if (timer) clearTimeout(timer);
|
|
for (const watcher of watchers) watcher.close();
|
|
};
|
|
}
|
|
|
|
function operatorViewPublication(value) {
|
|
return {
|
|
publicationId: "voidbot.swarm.operator-view",
|
|
documentType: "voidbot.swarm_operator_view",
|
|
schemaId: SCHEMA_ID,
|
|
recordKey: RECORD_KEY,
|
|
value,
|
|
};
|
|
}
|
|
|
|
function deriveSwarmState({ heartbeatReadable, paused, activeTurnCount, organs }) {
|
|
if (!heartbeatReadable) return "missing";
|
|
if (paused) return "paused";
|
|
if (organs.some((organ) => ["failed", "error", "stalled"].includes(organ.status))) return "warning";
|
|
if (activeTurnCount > 0) return "running";
|
|
return "ready";
|
|
}
|
|
|
|
function deriveOrchestratorState(organs, readable) {
|
|
if (!readable || organs.length === 0) return "missing";
|
|
if (organs.some((organ) => ["failed", "error", "stalled"].includes(organ.status))) return "warning";
|
|
if (organs.some((organ) => organ.status === "running")) return "running";
|
|
return "ready";
|
|
}
|
|
|
|
function selectNextTurn(participants) {
|
|
const next = [...participants]
|
|
.filter((participant) => participant.nextTurnInMinutes !== null)
|
|
.sort((left, right) => left.nextTurnInMinutes - right.nextTurnInMinutes)[0];
|
|
return next ? {
|
|
identityId: next.identityId,
|
|
displayName: next.displayName,
|
|
inMinutes: next.nextTurnInMinutes,
|
|
} : null;
|
|
}
|
|
|
|
function buildAlerts({ sources, organs }) {
|
|
const alerts = [];
|
|
for (const [source, status] of Object.entries(sources)) {
|
|
if (status?.readable === false) alerts.push({ code: `source.${source}.unreadable`, severity: "error", detail: text(status.error) || `${source} is unreadable` });
|
|
}
|
|
for (const organ of organs.filter((entry) => ["failed", "error", "stalled"].includes(entry.status))) {
|
|
alerts.push({ code: `organ.${organ.id}.${organ.status}`, severity: "warning", detail: `${organ.label} is ${organ.status}` });
|
|
}
|
|
return alerts;
|
|
}
|
|
|
|
function array(value) { return Array.isArray(value) ? value : []; }
|
|
function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
|
|
function text(value) { return typeof value === "string" && value.trim() ? value : null; }
|
|
function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }
|
|
function subtract(left, right) { return left === null || right === null ? null : Math.round((left - right) * 1000) / 1000; }
|
|
function nullableOrder(left, right) { return left === null ? 1 : right === null ? -1 : left - right; }
|
|
function compact(values) { return values.filter((value) => value !== null); }
|
|
function formatTurnDistance(value) { return value === null ? "unscheduled" : value <= 0 ? "ready now" : `turn in ${value}m`; }
|
|
|
|
module.exports = {
|
|
RECORD_KEY,
|
|
SCHEMA_ID,
|
|
operatorViewPublication,
|
|
projectSwarmOperatorView,
|
|
readSwarmOperatorInputs,
|
|
watchSwarmOperatorInputs,
|
|
};
|