Odin/src/odin-coordinator.cjs

203 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { buildConfig, loadCultRuntime } = require("./odin/config.cjs");
const { createCultNetRudpSurfaceServer } = require("./odin/cultnet-rudp.cjs");
const { defineOdinDocuments } = require("./odin/documents.cjs");
const { createInterfaceDiscovery } = require("./odin/interfaces.cjs");
const { createIdunnRudpHealthPublisher, publishIdunnRudpHealth } = require("./odin/idunn-rudp.cjs");
const { createLayoutStore } = require("./odin/layout.cjs");
const { createStateBuilder } = require("./odin/state.cjs");
const { broadcastState, createDashboardServer } = require("./odin/websocket.cjs");
const config = buildConfig(process.argv.slice(2));
fs.mkdirSync(config.stateDir, { recursive: true });
const idunnRudpHealthPublisher = createIdunnRudpHealthPublisher(config.idunnRudpHealth);
const cultRuntime = loadCultRuntime();
if (cultRuntime.error) {
console.error("CultMesh runtime unavailable; durable mesh snapshot disabled:", cultRuntime.error.message);
}
const documents = defineOdinDocuments(cultRuntime.defineDocumentType);
const layoutStore = createLayoutStore(config.layoutPath);
const interfaceDiscovery = createInterfaceDiscovery({
CultMesh: cultRuntime.CultMesh,
documents,
interfaceBindingStores: config.interfaceBindingStores,
seedDeckUrls: config.seedDeckUrls,
});
const stateBuilder = createStateBuilder({
cachePath: config.cachePath,
gamecultTextDocumentStorePath: config.gamecultTextDocumentStorePath,
interfaceDiscovery,
layoutStore,
observationFreshSeconds: config.observationFreshSeconds,
observationLogPath: config.observationLogPath,
stonksBurstSize: config.stonksBurstSize,
stonksStateUrl: config.stonksStateUrl,
});
let meshNodePromise = null;
let cultNetRudpSurfaceServer = null;
let currentState = stateBuilder.buildPendingState("Coordinator starting");
let lastRefresh = {
completedAt: null,
durationMs: null,
error: null,
startedAt: null,
};
let lastIdunnRudpHealthPublishedAt = null;
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
async function main() {
if (cultRuntime.CultMesh && documents.surfaceDefinition) {
meshNodePromise = createDurableSurfaceNode();
}
if (config.cultnetRudpBind) {
cultNetRudpSurfaceServer = createCultNetRudpSurfaceServer({
bind: config.cultnetRudpBind,
documents,
getCache: async () => {
if (!meshNodePromise) {
throw new Error("CultMesh runtime is unavailable; Odin cannot serve a CultNet/RUDP snapshot.");
}
return (await meshNodePromise).cache;
},
});
await cultNetRudpSurfaceServer.start();
console.log(`CultNet/RUDP snapshot: ${config.cultnetRudpBind}`);
}
const dashboardServer = createDashboardServer({
applyClientCommand: (command) => layoutStore.applyClientCommand(command),
getCurrentState: () => currentState,
getHealth: () => health(dashboardServer.clients),
host: config.host,
port: config.port,
});
console.log(`Durable surface cache: ${config.cachePath}`);
await refresh(dashboardServer.clients);
scheduleRefresh(dashboardServer.clients);
}
function scheduleRefresh(clients) {
setTimeout(() => {
refresh(clients)
.catch((error) => console.error("refresh failed:", error))
.finally(() => scheduleRefresh(clients));
}, config.intervalMs);
}
async function createDurableSurfaceNode() {
try {
return await cultRuntime.CultMesh.createNode(config.cachePath, { documents: [documents.surfaceDefinition] });
} catch (error) {
if (!fs.existsSync(config.cachePath)) {
throw error;
}
const corruptPath = `${config.cachePath}.corrupt-${new Date().toISOString().replace(/[:.]/g, "-")}`;
fs.renameSync(config.cachePath, corruptPath);
console.error(`CultMesh surface cache was unreadable and has been quarantined: ${corruptPath}`);
console.error(`CultMesh surface cache read error: ${error.message}`);
return cultRuntime.CultMesh.createNode(config.cachePath, { documents: [documents.surfaceDefinition] });
}
}
async function refresh(clients) {
const started = Date.now();
lastRefresh = {
completedAt: null,
durationMs: null,
error: null,
startedAt: new Date(started).toISOString(),
};
try {
currentState = await stateBuilder.buildState();
await persistState(currentState);
broadcastState(clients, currentState);
lastRefresh = {
completedAt: new Date().toISOString(),
durationMs: Date.now() - started,
error: null,
startedAt: new Date(started).toISOString(),
};
await publishOdinHealth("active", `Odin provider catalog refreshed in ${lastRefresh.durationMs}ms`);
} catch (error) {
lastRefresh = {
completedAt: new Date().toISOString(),
durationMs: Date.now() - started,
error: error.message,
startedAt: new Date(started).toISOString(),
};
await publishOdinHealth("failed", `Odin provider refresh failed: ${error.message}`);
throw error;
}
}
async function publishOdinHealth(state, detail) {
if (!idunnRudpHealthPublisher) return;
try {
await publishIdunnRudpHealth(idunnRudpHealthPublisher, {
state,
detail,
observedAt: new Date().toISOString(),
});
lastIdunnRudpHealthPublishedAt = Date.now();
} catch (error) {
const lastPublishedAgeMs = lastIdunnRudpHealthPublishedAt === null
? Number.POSITIVE_INFINITY
: Date.now() - lastIdunnRudpHealthPublishedAt;
if (lastPublishedAgeMs > Math.max(60_000, config.intervalMs * 4)) {
console.error("Idunn RUDP health publish failed:", error.message);
}
}
}
async function persistState(state) {
fs.writeFileSync(path.join(config.stateDir, "latest-surface.json"), JSON.stringify(state, null, 2), "utf8");
if (!meshNodePromise || !documents.surfaceDefinition) {
return;
}
try {
const node = await meshNodePromise;
await node.put(documents.surfaceDefinition, config.surfaceKey, state);
await node.flush?.(true);
} catch (error) {
console.error("CultMesh snapshot write failed:", error.message);
}
}
function health(clients) {
return {
ok: !lastRefresh.error,
providerId: currentState.providerId,
version: currentState.version,
clients: clients.size,
cachePath: config.cachePath,
stateDir: config.stateDir,
layoutPath: config.layoutPath,
intervalMs: config.intervalMs,
cultMesh: {
available: Boolean(cultRuntime.CultMesh && documents.surfaceDefinition),
error: cultRuntime.error?.message || null,
},
discovery: {
cultNetRudpBind: config.cultnetRudpBind || null,
seedDeckUrls: config.seedDeckUrls,
discoveredDeckUrls: interfaceDiscovery.getDiscoveredDeckUrls(),
interfaceBindingStores: config.interfaceBindingStores,
},
refresh: lastRefresh,
};
}