1import { SEED_VERSION } from "../../seed/version.js";
2
3// Cache governance state in memory. TTL 1 hour.
4let cachedState = null;
5let cacheTimestamp = 0;
6const CACHE_TTL = 60 * 60 * 1000; // 1 hour
7
8/**
9 * Parse comma-separated HORIZON_URL into an array of directory URLs.
10 */
11function getDirectoryUrls() {
12 return (process.env.HORIZON_URL || "")
13 .split(",")
14 .map((u) => u.trim())
15 .filter(Boolean);
16}
17
18/**
19 * Compare two semver strings. Returns -1, 0, or 1.
20 * Returns 0 if either string is not valid semver.
21 */
22export function compareSemver(a, b) {
23 const pa = String(a).match(/^(\d+)\.(\d+)\.(\d+)/);
24 const pb = String(b).match(/^(\d+)\.(\d+)\.(\d+)/);
25 if (!pa || !pb) return 0;
26
27 for (let i = 1; i <= 3; i++) {
28 const na = Number(pa[i]);
29 const nb = Number(pb[i]);
30 if (na < nb) return -1;
31 if (na > nb) return 1;
32 }
33 return 0;
34}
35
36/**
37 * Compute compatibility status for a directory's governance policy.
38 */
39function computeStatus(currentVersion, gov) {
40 if (!gov.minimumSeedVersion && !gov.recommendedSeedVersion) {
41 return "no_policy";
42 }
43
44 if (gov.minimumSeedVersion) {
45 if (compareSemver(currentVersion, gov.minimumSeedVersion) < 0) {
46 return "non_compliant";
47 }
48 }
49
50 if (gov.recommendedSeedVersion) {
51 if (compareSemver(currentVersion, gov.recommendedSeedVersion) < 0) {
52 return "advisory";
53 }
54 }
55
56 return "compliant";
57}
58
59/**
60 * Fetch governance data from all configured directories.
61 * Returns the governance state object.
62 */
63export async function refreshGovernance() {
64 const urls = getDirectoryUrls();
65 const directories = [];
66
67 const results = await Promise.allSettled(
68 urls.map(async (url) => {
69 const res = await fetch(`${url}/horizon/governance`, {
70 signal: AbortSignal.timeout(3000),
71 });
72 if (!res.ok) return { url, status: "unreachable", lastChecked: new Date().toISOString() };
73 const data = await res.json();
74 const gov = data.governance || {};
75 return {
76 url,
77 minimumSeedVersion: gov.minimumSeedVersion || null,
78 recommendedSeedVersion: gov.recommendedSeedVersion || null,
79 status: computeStatus(SEED_VERSION, gov),
80 lastChecked: new Date().toISOString(),
81 };
82 })
83 );
84
85 for (const r of results) {
86 if (r.status === "fulfilled") {
87 directories.push(r.value);
88 } else {
89 // Promise rejected (network error)
90 directories.push({
91 url: "unknown",
92 status: "unreachable",
93 lastChecked: new Date().toISOString(),
94 });
95 }
96 }
97
98 // Compute summary: worst status across all directories
99 const statusPriority = { non_compliant: 0, advisory: 1, unreachable: 2, no_policy: 3, compliant: 4 };
100 let worst = "compliant";
101 for (const d of directories) {
102 if ((statusPriority[d.status] ?? 5) < (statusPriority[worst] ?? 5)) {
103 worst = d.status;
104 }
105 }
106
107 cachedState = {
108 currentSeedVersion: SEED_VERSION,
109 directories,
110 summary: worst,
111 };
112 cacheTimestamp = Date.now();
113
114 return cachedState;
115}
116
117/**
118 * Get the cached governance state. Returns null if never fetched.
119 */
120export function getGovernanceState() {
121 if (cachedState && Date.now() - cacheTimestamp < CACHE_TTL) {
122 return cachedState;
123 }
124 return cachedState; // Return stale data rather than null. Refresh happens in background.
125}
126
127// ─────────────────────────────────────────────────────────────────────────
128// EXTENSION UPDATE CHECK
129// Cached separately from governance. Refreshed on the same hourly cycle.
130// ─────────────────────────────────────────────────────────────────────────
131
132let cachedUpdates = null;
133let updateCacheTimestamp = 0;
134
135/**
136 * Check installed extensions against the registry for available updates.
137 * Returns { updates: [{ name, installed, available }], checkedAt }
138 */
139export async function checkExtensionUpdates() {
140 const urls = getDirectoryUrls();
141 if (urls.length === 0) return { updates: [], checkedAt: new Date().toISOString() };
142
143 try {
144 const { getLoadedManifests } = await import("../../extensions/loader.js");
145 const manifests = getLoadedManifests();
146 const horizonUrl = urls[0];
147
148 const res = await fetch(`${horizonUrl}/extensions?limit=100`, {
149 signal: AbortSignal.timeout(3000),
150 });
151 if (!res.ok) return { updates: [], checkedAt: new Date().toISOString() };
152
153 const data = await res.json();
154 const registryExts = data.extensions || data || [];
155
156 const registryMap = new Map();
157 for (const ext of registryExts) {
158 const existing = registryMap.get(ext.name);
159 if (!existing || compareSemver(ext.version, existing) > 0) {
160 registryMap.set(ext.name, ext.version);
161 }
162 }
163
164 const updates = [];
165 for (const m of manifests) {
166 const registryVersion = registryMap.get(m.name);
167 if (!registryVersion) continue;
168 if (compareSemver(registryVersion, m.version) > 0) {
169 updates.push({ name: m.name, installed: m.version, available: registryVersion });
170 }
171 }
172
173 cachedUpdates = { updates, checkedAt: new Date().toISOString() };
174 updateCacheTimestamp = Date.now();
175 return cachedUpdates;
176 } catch {
177 return cachedUpdates || { updates: [], checkedAt: new Date().toISOString() };
178 }
179}
180
181/**
182 * Get cached extension update info.
183 */
184export function getExtensionUpdates() {
185 if (cachedUpdates && Date.now() - updateCacheTimestamp < CACHE_TTL) {
186 return cachedUpdates;
187 }
188 return cachedUpdates;
189}
190
1import { getGovernanceState, refreshGovernance, checkExtensionUpdates, getExtensionUpdates } from "./core.js";
2import buildTools from "./tools.js";
3import jobs from "./jobs.js";
4
5export async function init(core) {
6 // Enrich AI context so land-manager always knows governance state
7 // and available extension updates
8 core.hooks.register("enrichContext", async ({ context, node }) => {
9 // Only inject at land root (zone: land)
10 if (node?.systemRole !== "land-root" && node?.systemRole) return;
11
12 const state = getGovernanceState();
13 if (state && state.directories.length > 0) {
14 context.governance = state;
15 }
16
17 const updates = getExtensionUpdates();
18 if (updates?.updates?.length > 0) {
19 context.extensionUpdates = {
20 count: updates.updates.length,
21 available: updates.updates.slice(0, 5).map(u => `${u.name}: v${u.installed} -> v${u.available}`),
22 checkedAt: updates.checkedAt,
23 };
24 }
25 }, "governance");
26
27 // Refresh governance data and extension updates after boot
28 core.hooks.register("afterBoot", async () => {
29 try {
30 await refreshGovernance();
31 } catch {
32 // Non-fatal. Will retry on next hourly job cycle.
33 }
34 try {
35 await checkExtensionUpdates();
36 } catch {
37 // Non-fatal.
38 }
39 }, "governance");
40
41 const tools = buildTools();
42
43 return {
44 tools,
45 jobs,
46 modeTools: [
47 { modeKey: "land:manager", toolNames: ["governance-status", "governance-check"] },
48 ],
49 exports: { getGovernanceState, refreshGovernance, checkExtensionUpdates, getExtensionUpdates },
50 };
51}
52
1import { refreshGovernance, checkExtensionUpdates } from "./core.js";
2
3const REFRESH_INTERVAL = 60 * 60 * 1000; // 1 hour
4let intervalId = null;
5
6export default [
7 {
8 name: "governance-refresh",
9 start() {
10 if (intervalId) return;
11 intervalId = setInterval(async () => {
12 try { await refreshGovernance(); } catch {}
13 try { await checkExtensionUpdates(); } catch {}
14 }, REFRESH_INTERVAL);
15 },
16 stop() {
17 if (intervalId) {
18 clearInterval(intervalId);
19 intervalId = null;
20 }
21 },
22 },
23];
24
1export default {
2 name: "governance",
3 version: "1.0.1",
4 builtFor: "seed",
5 description:
6 "Network governance visibility. Fetches governance policies from configured directories " +
7 "and surfaces them to the land-manager AI. Shows current seed version, minimum required " +
8 "version, recommended version, and compatibility status. Never forces updates. Only informs.",
9
10 needs: {
11 services: [],
12 models: [],
13 },
14
15 optional: {
16 extensions: ["land-manager"],
17 },
18
19 provides: {
20 routes: "./routes.js",
21 tools: true,
22 jobs: true,
23 hooks: {
24 listens: ["enrichContext", "afterBoot"],
25 },
26 cli: [
27 { command: "governance-status", scope: ["land"], description: "Governance compliance status (cached)", method: "GET", endpoint: "/land/governance" },
28 { command: "governance-check", scope: ["land"], description: "Live check against directory policies", method: "POST", endpoint: "/land/governance/check" },
29 ],
30 },
31};
32
1import { Router } from "express";
2import { getGovernanceState, refreshGovernance } from "./core.js";
3
4const router = Router();
5
6/**
7 * GET /api/v1/land/governance
8 * Returns the cached governance state. Used by CLI.
9 */
10router.get("/land/governance", (req, res) => {
11 const state = getGovernanceState();
12 if (!state) {
13 return res.json({ status: "ok", data: { message: "Governance data not yet fetched" } });
14 }
15 return res.json({ status: "ok", data: state });
16});
17
18/**
19 * POST /api/v1/land/governance/check
20 * Live check against directory policies (bypasses cache).
21 */
22router.post("/land/governance/check", async (req, res) => {
23 try {
24 const state = await refreshGovernance();
25 return res.json({ status: "ok", data: state });
26 } catch (err) {
27 return res.status(500).json({ status: "error", error: { code: "INTERNAL", message: err.message } });
28 }
29});
30
31export default router;
32
1import { getGovernanceState, refreshGovernance } from "./core.js";
2
3export default function buildTools() {
4 return [
5 {
6 name: "governance-status",
7 description:
8 "Show governance status for this land. " +
9 "Returns current seed version, each directory's governance policy, " +
10 "and compatibility status (compliant, advisory, non_compliant, no_policy, unreachable).",
11 inputSchema: {
12 type: "object",
13 properties: {},
14 },
15 handler: async () => {
16 const state = getGovernanceState();
17 if (!state || state.directories.length === 0) {
18 return {
19 content: [{ type: "text", text: "No governance data available. No directories configured or data not yet fetched." }],
20 };
21 }
22
23 const lines = [`Seed version: ${state.currentSeedVersion}`, `Overall status: ${state.summary}`, ""];
24
25 for (const d of state.directories) {
26 lines.push(`Directory: ${d.url}`);
27 lines.push(` Status: ${d.status}`);
28 if (d.minimumSeedVersion) lines.push(` Minimum version: ${d.minimumSeedVersion}`);
29 if (d.recommendedSeedVersion) lines.push(` Recommended version: ${d.recommendedSeedVersion}`);
30 lines.push(` Last checked: ${d.lastChecked}`);
31 lines.push("");
32 }
33
34 return {
35 content: [{ type: "text", text: lines.join("\n") }],
36 };
37 },
38 },
39 {
40 name: "governance-check",
41 description:
42 "Force a fresh governance check against all configured directories. " +
43 "Bypasses cache and returns real-time data.",
44 inputSchema: {
45 type: "object",
46 properties: {},
47 },
48 handler: async () => {
49 const state = await refreshGovernance();
50 if (!state || state.directories.length === 0) {
51 return {
52 content: [{ type: "text", text: "No directories configured. Set HORIZON_URL to enable governance checks." }],
53 };
54 }
55
56 const lines = [`Seed version: ${state.currentSeedVersion}`, `Overall status: ${state.summary}`, ""];
57
58 for (const d of state.directories) {
59 lines.push(`Directory: ${d.url}`);
60 lines.push(` Status: ${d.status}`);
61 if (d.minimumSeedVersion) lines.push(` Minimum version: ${d.minimumSeedVersion}`);
62 if (d.recommendedSeedVersion) lines.push(` Recommended version: ${d.recommendedSeedVersion}`);
63 lines.push(` Last checked: ${d.lastChecked}`);
64 lines.push("");
65 }
66
67 return {
68 content: [{ type: "text", text: lines.join("\n") }],
69 };
70 },
71 },
72 ];
73}
74
Loading comments...