diff --git a/docs/voidbot-swarm-web-interface.md b/docs/voidbot-swarm-web-interface.md new file mode 100644 index 0000000..78c8ebd --- /dev/null +++ b/docs/voidbot-swarm-web-interface.md @@ -0,0 +1,51 @@ +# VoidBot Swarm Web Interface + +This is the read-only swarm visibility surface for iPad or LAN use. + +## Owner + +`scripts/render-voidbot-swarm-dashboard.mjs` owns the projection. It reads the existing state owners: + +- `.voidbot/status/repo-face-heartbeats.json` +- `.voidbot/status/gamecult-orchestrator.json` +- `state/agent-swarm-paused.json` + +It writes: + +- `.voidbot/status/swarm-state.json` +- `.voidbot/status/swarm-dashboard.html` +- `.voidbot/status/cultmesh/voidbot-swarm-state.cc` + +The `.cc` file is a CultCache snapshot document with schema id `voidbot.swarm_state_snapshot.v1`. CultMesh should distribute that document when a live mesh bridge is installed. The web page is only a reader. + +## Local iPad Use + +From `E:\Projects\VoidBot`: + +```powershell +npm run swarm:dashboard +``` + +Open the LAN URL printed by the command on the iPad. The server refreshes the snapshot every 10 seconds and serves the static page from `.voidbot/status`. + +For a one-shot render without serving: + +```powershell +npm run swarm:render-dashboard +``` + +## Public Hosting Shape + +`voidbot.gamecult.org` should not expose raw private state. Publish `swarm-state.json`, `swarm-dashboard.html`, and the CultCache snapshot only after deciding the access model: + +- public redacted view, if agent names, job ids, paths, and timing are acceptable +- private nginx/Heimdall-gated view, if the operational paths and job ids are sensitive +- Quartz-hosted static shell plus a separately synced redacted snapshot, if the domain should behave like the other GameCult static sites + +Do not make the public site read Face `.cc` files directly. The exporter is the redaction boundary. + +For public/static output, render with: + +```powershell +node scripts/render-voidbot-swarm-dashboard.mjs --public --out .voidbot/status/public-swarm-dashboard.html --snapshot .voidbot/status/public-swarm-state.json --cultmeshStore .voidbot/status/cultmesh/public-voidbot-swarm-state.cc +``` diff --git a/package.json b/package.json index 586f357..eac4ca4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "state:check": "powershell -ExecutionPolicy Bypass -File scripts/check-voidbot-operations.ps1", "state:dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1 -Open", "state:render-dashboard": "powershell -ExecutionPolicy Bypass -File scripts/render-voidbot-operations-dashboard.ps1", + "swarm:dashboard": "node scripts/render-voidbot-swarm-dashboard.mjs --serve --host 0.0.0.0", + "swarm:render-dashboard": "node scripts/render-voidbot-swarm-dashboard.mjs", "state:sync-offsite": "powershell -ExecutionPolicy Bypass -File scripts/sync-voidbot-backup-offsite.ps1", "state:install-startup-task": "powershell -ExecutionPolicy Bypass -File scripts/install-stack-startup-task.ps1", "state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1", diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs new file mode 100644 index 0000000..bb2d5f5 --- /dev/null +++ b/scripts/render-voidbot-swarm-dashboard.mjs @@ -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 ` + +
+ + +