Add VoidBot swarm dashboard
This commit is contained in:
parent
40cbdd075a
commit
6f95f22b13
4 changed files with 945 additions and 0 deletions
891
scripts/render-voidbot-swarm-dashboard.mjs
Normal file
891
scripts/render-voidbot-swarm-dashboard.mjs
Normal file
|
|
@ -0,0 +1,891 @@
|
|||
#!/usr/bin/env node
|
||||
import { createServer } from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import { networkInterfaces } from "node:os";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultStorageRoot = resolve(repoRoot, ".voidbot");
|
||||
const defaultStatusDir = resolve(defaultStorageRoot, "status");
|
||||
const defaultSnapshotPath = resolve(defaultStatusDir, "swarm-state.json");
|
||||
const defaultDashboardPath = resolve(defaultStatusDir, "swarm-dashboard.html");
|
||||
const defaultCultMeshStorePath = resolve(defaultStatusDir, "cultmesh", "voidbot-swarm-state.cc");
|
||||
const snapshotDocumentType = "voidbot.swarm_state_snapshot";
|
||||
const snapshotSchemaId = "voidbot.swarm_state_snapshot.v1";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const env = await readDotEnv(resolve(repoRoot, ".env"));
|
||||
const storageRoot = resolveConfigPath(env.STORAGE_ROOT, defaultStorageRoot);
|
||||
const statusDir = resolve(storageRoot, "status");
|
||||
const heartbeatStatePath = resolveConfigPath(
|
||||
env.REPO_FACE_HEARTBEAT_STATE_PATH,
|
||||
resolve(statusDir, "repo-face-heartbeats.json"),
|
||||
);
|
||||
const orchestratorPath = resolve(statusDir, "gamecult-orchestrator.json");
|
||||
const pausePath = resolve(repoRoot, "state", "agent-swarm-paused.json");
|
||||
const snapshotPath = resolveConfigPath(args.snapshot, defaultSnapshotPath.replace(defaultStatusDir, statusDir));
|
||||
const dashboardPath = resolveConfigPath(args.out, defaultDashboardPath.replace(defaultStatusDir, statusDir));
|
||||
const cultMeshStorePath = resolveConfigPath(
|
||||
args.cultmeshStore,
|
||||
defaultCultMeshStorePath.replace(defaultStatusDir, statusDir),
|
||||
);
|
||||
|
||||
const renderResult = await render();
|
||||
|
||||
if (args.open) {
|
||||
console.log(pathToFileURL(renderResult.dashboardPath).href);
|
||||
}
|
||||
|
||||
if (args.serve) {
|
||||
await serveDashboard({
|
||||
host: args.host ?? "0.0.0.0",
|
||||
port: Number.parseInt(args.port ?? "8787", 10),
|
||||
rootDir: dirname(renderResult.dashboardPath),
|
||||
refreshSeconds: Number.parseInt(args.refreshSeconds ?? "10", 10),
|
||||
});
|
||||
} else {
|
||||
console.log(`Swarm dashboard HTML: ${renderResult.dashboardPath}`);
|
||||
console.log(`Swarm snapshot JSON: ${renderResult.snapshotPath}`);
|
||||
console.log(`CultMesh snapshot store: ${renderResult.cultMeshStorePath}`);
|
||||
}
|
||||
|
||||
async function render() {
|
||||
let snapshot = await buildSnapshot();
|
||||
if (args.public) {
|
||||
snapshot = redactSnapshot(snapshot);
|
||||
}
|
||||
await mkdir(dirname(snapshotPath), { recursive: true });
|
||||
await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
||||
|
||||
await mkdir(dirname(dashboardPath), { recursive: true });
|
||||
await writeFile(dashboardPath, renderHtml(snapshot), "utf8");
|
||||
|
||||
const cultMeshWrite = await writeCultMeshSnapshot(snapshot, cultMeshStorePath);
|
||||
const finalSnapshot = {
|
||||
...snapshot,
|
||||
cultMesh: {
|
||||
...snapshot.cultMesh,
|
||||
...cultMeshWrite,
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(snapshotPath, `${JSON.stringify(finalSnapshot, null, 2)}\n`, "utf8");
|
||||
await writeFile(dashboardPath, renderHtml(finalSnapshot), "utf8");
|
||||
|
||||
return {
|
||||
dashboardPath,
|
||||
snapshotPath,
|
||||
cultMeshStorePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSnapshot() {
|
||||
const now = new Date();
|
||||
const heartbeat = await readJsonFile(heartbeatStatePath);
|
||||
const orchestrator = await readJsonFile(orchestratorPath);
|
||||
const pause = await readJsonFile(pausePath);
|
||||
const heartbeatMtime = await getMtime(heartbeatStatePath);
|
||||
const orchestratorMtime = await getMtime(orchestratorPath);
|
||||
const paused = pause.ok ? pause.value?.paused !== false : false;
|
||||
const pauseReason = pause.ok && typeof pause.value?.reason === "string" ? pause.value.reason : undefined;
|
||||
const participants = Array.isArray(heartbeat.value?.participants) ? heartbeat.value.participants : [];
|
||||
const pendingMentions = Array.isArray(heartbeat.value?.pendingMentions) ? heartbeat.value.pendingMentions : [];
|
||||
const initiativeClock = numberOrNull(heartbeat.value?.initiativeClock);
|
||||
const participantSnapshots = participants.map((participant) =>
|
||||
projectParticipant(participant, pendingMentions, initiativeClock),
|
||||
);
|
||||
const activeTurns = participantSnapshots.filter((participant) => participant.activeJobId);
|
||||
const readyNow = participantSnapshots.filter((participant) =>
|
||||
participant.status === "active" &&
|
||||
!participant.activeJobId &&
|
||||
typeof participant.nextTurnInMinutes === "number" &&
|
||||
participant.nextTurnInMinutes <= 0,
|
||||
);
|
||||
const nextParticipant = [...participantSnapshots]
|
||||
.filter((participant) => typeof participant.nextTurnInMinutes === "number")
|
||||
.sort((left, right) => left.nextTurnInMinutes - right.nextTurnInMinutes)[0];
|
||||
const organs = projectOrgans(orchestrator.value);
|
||||
const recentEvents = Array.isArray(heartbeat.value?.history)
|
||||
? heartbeat.value.history.slice(-24).reverse().map(projectHistoryEvent)
|
||||
: [];
|
||||
|
||||
return {
|
||||
schemaVersion: snapshotSchemaId,
|
||||
generatedAt: now.toISOString(),
|
||||
title: "VoidBot Swarm State",
|
||||
sources: {
|
||||
heartbeatStatePath,
|
||||
heartbeatStateUpdatedAt: heartbeatMtime,
|
||||
heartbeatStateReadable: heartbeat.ok,
|
||||
heartbeatStateError: heartbeat.ok ? undefined : heartbeat.error,
|
||||
orchestratorPath,
|
||||
orchestratorUpdatedAt: orchestratorMtime,
|
||||
orchestratorReadable: orchestrator.ok,
|
||||
orchestratorError: orchestrator.ok ? undefined : orchestrator.error,
|
||||
pausePath,
|
||||
pauseReadable: pause.ok,
|
||||
pauseError: pause.ok ? undefined : pause.error,
|
||||
},
|
||||
cultMesh: {
|
||||
documentType: snapshotDocumentType,
|
||||
schemaId: snapshotSchemaId,
|
||||
documentKey: "voidbot-swarm",
|
||||
storePath: cultMeshStorePath,
|
||||
writeStatus: "pending",
|
||||
note: "CultMesh should distribute this typed CultCache snapshot; the web page remains read-only.",
|
||||
},
|
||||
summary: {
|
||||
state: deriveSwarmState({ heartbeat, orchestrator, paused, activeTurns }),
|
||||
paused,
|
||||
pauseReason,
|
||||
participantCount: participantSnapshots.length,
|
||||
activeTurnCount: activeTurns.length,
|
||||
pendingMentionCount: pendingMentions.length,
|
||||
readyNowCount: readyNow.length,
|
||||
nextIdentityId: nextParticipant?.identityId ?? null,
|
||||
nextDisplayName: nextParticipant?.displayName ?? null,
|
||||
nextTurnInMinutes: nextParticipant?.nextTurnInMinutes ?? null,
|
||||
initiativeClock,
|
||||
baseRecoveryMinutes: numberOrNull(heartbeat.value?.baseRecoveryMinutes),
|
||||
globalHeat: numberOrNull(heartbeat.value?.globalHeat),
|
||||
lastTickAt: stringOrNull(heartbeat.value?.lastTickAt),
|
||||
},
|
||||
participants: participantSnapshots,
|
||||
activeTurns,
|
||||
pendingMentions: pendingMentions.map(projectPendingMention),
|
||||
orchestrator: {
|
||||
state: deriveOrchestratorState(organs),
|
||||
organs,
|
||||
},
|
||||
recentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function projectParticipant(participant, pendingMentions, initiativeClock) {
|
||||
const identityId = String(participant.identityId ?? "unknown");
|
||||
const nextTurnAt = numberOrNull(participant.nextTurnAt);
|
||||
const lastTurnAt = numberOrNull(participant.lastTurnAt);
|
||||
const activeTurnStartedAt = numberOrNull(participant.activeTurnStartedAt);
|
||||
const channelCount = Array.isArray(participant.groups)
|
||||
? participant.groups.filter((entry) => typeof entry === "string" && entry.startsWith("channel:")).length
|
||||
: 0;
|
||||
const mentionCount = pendingMentions.filter((mention) => mention?.identityId === identityId).length;
|
||||
const nextTurnInMinutes =
|
||||
typeof nextTurnAt === "number" && typeof initiativeClock === "number"
|
||||
? round(nextTurnAt - initiativeClock, 3)
|
||||
: null;
|
||||
|
||||
return {
|
||||
identityId,
|
||||
displayName: String(participant.displayName ?? identityId),
|
||||
repoName: String(participant.repoName ?? "unknown"),
|
||||
participantKind: String(participant.participantKind ?? "repo_face"),
|
||||
turnKind: String(participant.turnKind ?? "repo_face_rumination"),
|
||||
status: String(participant.status ?? "unknown"),
|
||||
currentLoad: numberOrNull(participant.currentLoad),
|
||||
heat: numberOrNull(participant.heat),
|
||||
effectiveSpeed: numberOrNull(participant.effectiveSpeed),
|
||||
initiativeSpeed: numberOrNull(participant.initiativeSpeed),
|
||||
nextTurnAt,
|
||||
nextTurnInMinutes,
|
||||
lastTurnAt,
|
||||
activeTurnStartedAt,
|
||||
activeJobId: stringOrNull(participant.activeJobId),
|
||||
lastQueuedAt: stringOrNull(participant.lastQueuedAt),
|
||||
queuedCount: numberOrNull(participant.queuedCount),
|
||||
pendingMentionCount: mentionCount,
|
||||
channelCount,
|
||||
constraintCount: Array.isArray(participant.constraints) ? participant.constraints.length : 0,
|
||||
constraints: Array.isArray(participant.constraints) ? participant.constraints.slice(0, 4).map(String) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function projectOrgans(orchestrator) {
|
||||
const organs = orchestrator?.organs && typeof orchestrator.organs === "object" ? orchestrator.organs : {};
|
||||
return Object.entries(organs).map(([id, organ]) => ({
|
||||
id,
|
||||
label: String(organ?.label ?? id),
|
||||
intervalMinutes: numberOrNull(organ?.intervalMinutes),
|
||||
lastStartedAt: stringOrNull(organ?.lastStartedAt),
|
||||
lastFinishedAt: stringOrNull(organ?.lastFinishedAt),
|
||||
lastExitCode: numberOrNull(organ?.lastExitCode),
|
||||
lastStatus: String(organ?.lastStatus ?? "unknown"),
|
||||
lastLogPath: stringOrNull(organ?.lastLogPath),
|
||||
}));
|
||||
}
|
||||
|
||||
function projectHistoryEvent(event) {
|
||||
return {
|
||||
type: String(event?.type ?? "event"),
|
||||
identityId: stringOrNull(event?.identityId),
|
||||
observedAt: stringOrNull(event?.observedAt ?? event?.queuedAt),
|
||||
activeJobId: stringOrNull(event?.activeJobId),
|
||||
statusPath: stringOrNull(event?.statusPath),
|
||||
reason: stringOrNull(event?.reason),
|
||||
nextTurnAt: numberOrNull(event?.nextTurnAt),
|
||||
recoveryMinutes: numberOrNull(event?.recoveryMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
function projectPendingMention(mention) {
|
||||
return {
|
||||
identityId: stringOrNull(mention?.identityId),
|
||||
sourceChannelId: stringOrNull(mention?.sourceChannelId),
|
||||
sourceMessageId: stringOrNull(mention?.sourceMessageId),
|
||||
createdAt: stringOrNull(mention?.createdAt),
|
||||
prompt: truncate(String(mention?.prompt ?? mention?.content ?? ""), 220),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveSwarmState({ heartbeat, orchestrator, paused, activeTurns }) {
|
||||
if (!heartbeat.ok) return "missing";
|
||||
if (paused) return "paused";
|
||||
const organ = projectOrgans(orchestrator.value).find((entry) => entry.id === "repo-face-heartbeats");
|
||||
if (organ && organ.lastStatus && !["ok", "skipped_disabled"].includes(organ.lastStatus)) {
|
||||
return "warning";
|
||||
}
|
||||
if (activeTurns.length > 0) return "running";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function deriveOrchestratorState(organs) {
|
||||
if (organs.length === 0) return "missing";
|
||||
if (organs.some((organ) => ["failed", "error", "stalled"].includes(organ.lastStatus))) return "warning";
|
||||
if (organs.some((organ) => organ.lastStatus === "running")) return "running";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
async function writeCultMeshSnapshot(snapshot, storePath) {
|
||||
try {
|
||||
const cultCacheRoot = resolve(repoRoot, "..", "CultCacheTS");
|
||||
const cultCachePackage = resolve(cultCacheRoot, "package.json");
|
||||
if (!existsSync(cultCachePackage)) {
|
||||
return {
|
||||
writeStatus: "skipped",
|
||||
writeError: `CultCacheTS package was not found at ${cultCachePackage}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const requireCultCache = createRequire(cultCachePackage);
|
||||
const { CultCache, SingleFileMessagePackBackingStore, defineDocumentType } = requireCultCache(
|
||||
resolve(cultCacheRoot, "dist", "index.js"),
|
||||
);
|
||||
const snapshotDefinition = defineDocumentType({
|
||||
type: snapshotDocumentType,
|
||||
schemaName: snapshotDocumentType,
|
||||
schemaId: snapshotSchemaId,
|
||||
schemaVersion: snapshotSchemaId,
|
||||
contentHash: snapshotSchemaId,
|
||||
canonicalSchemaJson: JSON.stringify({
|
||||
schemaName: snapshotDocumentType,
|
||||
schemaVersion: snapshotSchemaId,
|
||||
description: "Redacted read-only VoidBot swarm state snapshot for CultMesh distribution.",
|
||||
}),
|
||||
global: true,
|
||||
schema: {
|
||||
parse(input) {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new Error("VoidBot swarm snapshot must be an object.");
|
||||
}
|
||||
return input;
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = new SingleFileMessagePackBackingStore(storePath);
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(snapshotDefinition)
|
||||
.withGenericStore(store)
|
||||
.build();
|
||||
await cache.pullAllBackingStores();
|
||||
await cache.put(snapshotDefinition, "voidbot-swarm", snapshot);
|
||||
return {
|
||||
writeStatus: "ok",
|
||||
storePath,
|
||||
writtenAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
writeStatus: "failed",
|
||||
storePath,
|
||||
writeError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderHtml(snapshot) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VoidBot Swarm State</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101113;
|
||||
--panel: #181a1d;
|
||||
--panel-2: #202327;
|
||||
--line: #343941;
|
||||
--text: #f1efe7;
|
||||
--muted: #aaa69a;
|
||||
--green: #61d394;
|
||||
--amber: #e6b450;
|
||||
--coral: #e06c66;
|
||||
--cyan: #55b8c6;
|
||||
--violet: #a891e8;
|
||||
--ink: #070808;
|
||||
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--mono: "SFMono-Regular", "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font: 15px/1.5 var(--font);
|
||||
letter-spacing: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(16, 17, 19, 0.98), rgba(16, 17, 19, 1)),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 {
|
||||
font-size: clamp(2rem, 5vw, 4.5rem);
|
||||
line-height: 0.95;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 720;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric, section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.agent-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.agent h3 {
|
||||
font-size: 1.08rem;
|
||||
font-weight: 760;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.repo {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.76rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fact {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fact span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fact strong {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tables {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 9px 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
code, .mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink);
|
||||
background: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ready, .ok, .active { background: var(--green); }
|
||||
.running, .info { background: var(--cyan); }
|
||||
.paused, .warning { background: var(--amber); }
|
||||
.failed, .missing, .error { background: var(--coral); }
|
||||
.repo-face { background: var(--violet); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.footer {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
main { padding: 12px; }
|
||||
.summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.participants { grid-template-columns: 1fr; }
|
||||
.tables { grid-template-columns: 1fr; }
|
||||
.facts { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script id="initial-snapshot" type="application/json">${escapeScriptJson(JSON.stringify(snapshot))}</script>
|
||||
<script>
|
||||
const initialSnapshot = JSON.parse(document.getElementById("initial-snapshot").textContent);
|
||||
const app = document.getElementById("app");
|
||||
const tone = (value) => String(value || "unknown").toLowerCase().replace(/_/g, "-");
|
||||
const text = (value) => value === null || value === undefined || value === "" ? "missing" : String(value);
|
||||
const number = (value) => typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "missing";
|
||||
const minutes = (value) => typeof value === "number" ? (value <= 0 ? "ready" : value.toLocaleString(undefined, { maximumFractionDigits: 1 }) + "m") : "missing";
|
||||
const relative = (timestamp) => {
|
||||
if (!timestamp) return "missing";
|
||||
const time = Date.parse(timestamp);
|
||||
if (!Number.isFinite(time)) return timestamp;
|
||||
const seconds = Math.max(0, Math.round((Date.now() - time) / 1000));
|
||||
if (seconds < 60) return seconds + "s ago";
|
||||
const mins = Math.round(seconds / 60);
|
||||
if (mins < 60) return mins + "m ago";
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 48) return hours + "h ago";
|
||||
return Math.round(hours / 24) + "d ago";
|
||||
};
|
||||
const esc = (value) => text(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
const badge = (value, label = value) => "<span class=\\"badge " + tone(value) + "\\">" + esc(String(label).replace(/_/g, " ")) + "</span>";
|
||||
const metric = (label, value, klass = "") => "<article class=\\"metric\\"><span>" + esc(label) + "</span><strong class=\\"" + klass + "\\">" + esc(value) + "</strong></article>";
|
||||
|
||||
function render(snapshot) {
|
||||
const summary = snapshot.summary || {};
|
||||
const participants = [...(snapshot.participants || [])].sort((a, b) => {
|
||||
const active = Number(Boolean(b.activeJobId)) - Number(Boolean(a.activeJobId));
|
||||
if (active) return active;
|
||||
return (a.nextTurnInMinutes ?? 999999) - (b.nextTurnInMinutes ?? 999999);
|
||||
});
|
||||
const organs = snapshot.orchestrator?.organs || [];
|
||||
const events = snapshot.recentEvents || [];
|
||||
app.innerHTML = [
|
||||
"<header>",
|
||||
"<div class=\\"topline\\">" + badge(summary.state) + "<span>Generated " + esc(relative(snapshot.generatedAt)) + "</span><span class=\\"mono\\">" + esc(snapshot.cultMesh?.schemaId) + "</span></div>",
|
||||
"<h1>VoidBot Swarm State</h1>",
|
||||
"<p class=\\"muted\\">Read-only CTB scheduler state, rendered from the local status files and mirrored as a CultCache snapshot for CultMesh.</p>",
|
||||
"</header>",
|
||||
"<section class=\\"summary\\">",
|
||||
metric("Participants", number(summary.participantCount)),
|
||||
metric("Active turns", number(summary.activeTurnCount)),
|
||||
metric("Pending mentions", number(summary.pendingMentionCount)),
|
||||
metric("Ready now", number(summary.readyNowCount)),
|
||||
metric("Next", summary.nextDisplayName ? summary.nextDisplayName : "missing"),
|
||||
metric("Next turn", minutes(summary.nextTurnInMinutes)),
|
||||
metric("Global heat", number(summary.globalHeat)),
|
||||
metric("CultMesh", snapshot.cultMesh?.writeStatus || "missing"),
|
||||
"</section>",
|
||||
"<section>",
|
||||
"<h2>Agents</h2>",
|
||||
"<div class=\\"participants\\">",
|
||||
participants.map(agentCard).join(""),
|
||||
"</div>",
|
||||
"</section>",
|
||||
"<div class=\\"tables\\">",
|
||||
"<section><h2>Orchestrator</h2><table><thead><tr><th>Organ</th><th>Status</th><th>Last run</th></tr></thead><tbody>",
|
||||
organs.length ? organs.map((organ) => "<tr><td>" + esc(organ.label) + "<div class=\\"repo\\">" + esc(organ.id) + "</div></td><td>" + badge(organ.lastStatus) + "</td><td>" + esc(relative(organ.lastFinishedAt || organ.lastStartedAt)) + "</td></tr>").join("") : "<tr><td colspan=\\"3\\" class=\\"muted\\">No orchestrator state.</td></tr>",
|
||||
"</tbody></table></section>",
|
||||
"<section><h2>Recent Events</h2><table><thead><tr><th>Event</th><th>Identity</th><th>When</th></tr></thead><tbody>",
|
||||
events.length ? events.slice(0, 16).map((event) => "<tr><td>" + esc(event.type) + (event.reason ? "<div class=\\"repo\\">" + esc(event.reason) + "</div>" : "") + "</td><td>" + esc(event.identityId || "") + "</td><td>" + esc(relative(event.observedAt)) + "</td></tr>").join("") : "<tr><td colspan=\\"3\\" class=\\"muted\\">No heartbeat events.</td></tr>",
|
||||
"</tbody></table></section>",
|
||||
"</div>",
|
||||
"<section><h2>State Files</h2><table><tbody>",
|
||||
row("Heartbeat", snapshot.sources?.heartbeatStatePath, snapshot.sources?.heartbeatStateReadable ? "ok" : "missing"),
|
||||
row("Orchestrator", snapshot.sources?.orchestratorPath, snapshot.sources?.orchestratorReadable ? "ok" : "missing"),
|
||||
row("Pause flag", snapshot.sources?.pausePath, snapshot.sources?.pauseReadable ? (summary.paused ? "paused" : "ok") : "missing"),
|
||||
row("CultMesh store", snapshot.cultMesh?.storePath, snapshot.cultMesh?.writeStatus),
|
||||
"</tbody></table></section>",
|
||||
"<div class=\\"footer\\">This page polls <code>swarm-state.json</code> every 10 seconds when served over HTTP. It does not mutate VoidBot, Discord, CultCache, or the scheduler.</div>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function agentCard(agent) {
|
||||
return [
|
||||
"<article class=\\"agent\\">",
|
||||
"<div class=\\"agent-head\\"><div><h3>" + esc(agent.displayName) + "</h3><div class=\\"repo\\">" + esc(agent.identityId) + " / " + esc(agent.repoName) + "</div></div>" + badge(agent.activeJobId ? "running" : agent.status) + "</div>",
|
||||
"<div class=\\"facts\\">",
|
||||
fact("Next", minutes(agent.nextTurnInMinutes)),
|
||||
fact("Load", number(agent.currentLoad)),
|
||||
fact("Speed", number(agent.effectiveSpeed)),
|
||||
fact("Heat", number(agent.heat)),
|
||||
fact("Queued", number(agent.queuedCount)),
|
||||
fact("Mentions", number(agent.pendingMentionCount)),
|
||||
fact("Channels", number(agent.channelCount)),
|
||||
fact("Last queued", relative(agent.lastQueuedAt)),
|
||||
"</div>",
|
||||
agent.activeJobId ? "<div class=\\"repo\\">active " + esc(agent.activeJobId) + "</div>" : "",
|
||||
"</article>",
|
||||
].join("");
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return "<div class=\\"fact\\"><span>" + esc(label) + "</span><strong>" + esc(value) + "</strong></div>";
|
||||
}
|
||||
|
||||
function row(label, value, state) {
|
||||
return "<tr><th>" + esc(label) + "</th><td class=\\"mono\\">" + esc(value) + "</td><td>" + badge(state || "unknown") + "</td></tr>";
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch("swarm-state.json?ts=" + Date.now(), { cache: "no-store" });
|
||||
if (response.ok) {
|
||||
render(await response.json());
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
render(initialSnapshot);
|
||||
}
|
||||
|
||||
render(initialSnapshot);
|
||||
setInterval(refresh, 10000);
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function redactSnapshot(snapshot) {
|
||||
const redacted = JSON.parse(JSON.stringify(snapshot));
|
||||
redacted.visibility = "public_redacted";
|
||||
redacted.sources = {
|
||||
heartbeatStateReadable: snapshot.sources?.heartbeatStateReadable ?? false,
|
||||
heartbeatStateUpdatedAt: snapshot.sources?.heartbeatStateUpdatedAt ?? null,
|
||||
orchestratorReadable: snapshot.sources?.orchestratorReadable ?? false,
|
||||
orchestratorUpdatedAt: snapshot.sources?.orchestratorUpdatedAt ?? null,
|
||||
pauseReadable: snapshot.sources?.pauseReadable ?? false,
|
||||
};
|
||||
redacted.cultMesh = {
|
||||
documentType: snapshot.cultMesh?.documentType,
|
||||
schemaId: snapshot.cultMesh?.schemaId,
|
||||
documentKey: snapshot.cultMesh?.documentKey,
|
||||
writeStatus: snapshot.cultMesh?.writeStatus,
|
||||
writtenAt: snapshot.cultMesh?.writtenAt,
|
||||
note: "Public redacted snapshot. Local paths and job ids are withheld.",
|
||||
};
|
||||
redacted.participants = redacted.participants.map((participant) => ({
|
||||
...participant,
|
||||
activeJobId: participant.activeJobId ? "active" : null,
|
||||
constraints: [],
|
||||
constraintCount: participant.constraintCount,
|
||||
}));
|
||||
redacted.activeTurns = redacted.activeTurns.map((participant) => ({
|
||||
...participant,
|
||||
activeJobId: participant.activeJobId ? "active" : null,
|
||||
constraints: [],
|
||||
}));
|
||||
redacted.pendingMentions = redacted.pendingMentions.map((mention) => ({
|
||||
identityId: mention.identityId,
|
||||
createdAt: mention.createdAt,
|
||||
}));
|
||||
redacted.orchestrator.organs = redacted.orchestrator.organs.map((organ) => ({
|
||||
...organ,
|
||||
lastLogPath: null,
|
||||
}));
|
||||
redacted.recentEvents = redacted.recentEvents.map((event) => ({
|
||||
...event,
|
||||
activeJobId: event.activeJobId ? "active" : null,
|
||||
statusPath: null,
|
||||
}));
|
||||
return redacted;
|
||||
}
|
||||
|
||||
async function serveDashboard({ host, port, rootDir, refreshSeconds }) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await render();
|
||||
} catch (error) {
|
||||
console.error(`Swarm dashboard refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, Math.max(refreshSeconds, 2) * 1000);
|
||||
interval.unref();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
||||
const pathname = requestUrl.pathname === "/" ? "/swarm-dashboard.html" : decodeURIComponent(requestUrl.pathname);
|
||||
const target = resolve(rootDir, `.${pathname}`);
|
||||
if (!target.startsWith(rootDir)) {
|
||||
response.writeHead(403);
|
||||
response.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
const fileStat = await stat(target);
|
||||
if (!fileStat.isFile()) {
|
||||
response.writeHead(404);
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType(target),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(await readFile(target));
|
||||
} catch {
|
||||
response.writeHead(404);
|
||||
response.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolveServer) => server.listen(port, host, resolveServer));
|
||||
console.log(`Swarm dashboard serving ${rootDir}`);
|
||||
for (const url of localUrls(port, host)) {
|
||||
console.log(url);
|
||||
}
|
||||
}
|
||||
|
||||
function contentType(path) {
|
||||
switch (extname(path).toLowerCase()) {
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".cc":
|
||||
return "application/octet-stream";
|
||||
default:
|
||||
return "text/plain; charset=utf-8";
|
||||
}
|
||||
}
|
||||
|
||||
function localUrls(port, host) {
|
||||
const urls = [];
|
||||
if (host === "127.0.0.1" || host === "localhost") {
|
||||
urls.push(`http://127.0.0.1:${port}/`);
|
||||
return urls;
|
||||
}
|
||||
urls.push(`http://127.0.0.1:${port}/`);
|
||||
for (const entries of Object.values(networkInterfaces())) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.family === "IPv4" && !entry.internal) {
|
||||
urls.push(`http://${entry.address}:${port}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
async function readJsonFile(path) {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return { ok: true, value: JSON.parse(stripBom(raw)) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getMtime(path) {
|
||||
try {
|
||||
return (await stat(path)).mtime.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDotEnv(path) {
|
||||
const values = {};
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator < 1) continue;
|
||||
values[line.slice(0, separator).trim()] = line.slice(separator + 1);
|
||||
}
|
||||
} catch {}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveConfigPath(value, fallback) {
|
||||
const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
return resolve(repoRoot, raw);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--serve") parsed.serve = true;
|
||||
else if (arg === "--open") parsed.open = true;
|
||||
else if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
parsed[key] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
parsed[key] = "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stripBom(value) {
|
||||
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
function numberOrNull(value) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function stringOrNull(value) {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function round(value, places) {
|
||||
const factor = 10 ** places;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function truncate(value, maxLength) {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||
}
|
||||
|
||||
function escapeScriptJson(value) {
|
||||
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
||||
}
|
||||
Loading…
Reference in a new issue