This repository has no description
8.7 kB
239 lines
1// ─── Strata Analysis Container Server ────────────────────────────────
2// HTTP server running inside the Cloudflare Container.
3// Endpoints:
4// POST /analyze — run strata analysis on an island
5// POST /deriveIsland — derive island (connected component) from a lexmin vertex
6// POST /sync — sync vault + carry data
7// GET /health — health check
8
9import { analyze } from "./analysis.ts";
10import { syncAll, syncVault, syncCarry } from "./sync.ts";
11import { createServer } from "http";
12import {
13 execSync,
14} from "child_process";
15
16const PORT = 8080;
17const CARRY_PATH = process.env.CARRY_PATH || "/data/carry";
18
19createServer(async (req, res) => {
20 const url = new URL(req.url || "/", `http://localhost:${PORT}`);
21
22 // ── Health ──
23 if (url.pathname === "/health" && req.method === "GET") {
24 // Include data status in health check
25 const { readdirSync, statSync, existsSync } = await import("fs");
26 const { join } = await import("path");
27 let vaultFiles = 0, carryFiles = 0, carryDomains: string[] = [];
28 try {
29 const walk = (dir: string): number => {
30 let count = 0;
31 for (const e of readdirSync(dir, { withFileTypes: true })) {
32 if (e.name.startsWith(".")) continue;
33 if (e.isDirectory()) count += walk(join(dir, e.name));
34 else if (e.name.endsWith(".md")) count++;
35 }
36 return count;
37 };
38 vaultFiles = walk("/data/vault");
39 const carryBase = "/data/carry";
40 try {
41 for (const d of readdirSync(carryBase)) {
42 if (d.startsWith(".")) continue;
43 const p = join(carryBase, d);
44 if (statSync(p).isDirectory()) {
45 carryDomains.push(d);
46 carryFiles += walk(p);
47 }
48 }
49 } catch {}
50 } catch {}
51 res.writeHead(200, { "Content-Type": "application/json" });
52 res.end(JSON.stringify({
53 status: "ok",
54 vaultFiles,
55 carryFiles,
56 carryDomains,
57 carryTokenSet: !!process.env.CARRY_GITHUB_TOKEN,
58 obsidianTokenSet: !!process.env.OBSIDIAN_AUTH_TOKEN,
59 obsidianConfigDir: existsSync("/root/.config/obsidian-headless/auth_token"),
60 }));
61 return;
62 }
63
64 // ── Sync ──
65 if (url.pathname === "/sync" && req.method === "POST") {
66 try {
67 const { execSync } = await import("child_process");
68 const { existsSync, readFileSync } = await import("fs");
69
70 // Sync vault
71 let vaultLog = "";
72 try {
73 const out = execSync("ob sync --path /data/vault 2>&1", { timeout: 120000, encoding: "utf-8" });
74 vaultLog = out.slice(-500);
75 } catch (e: any) {
76 vaultLog = e.stdout?.slice(-500) || "" + e.stderr?.slice(-500) || "" || e.message?.slice(-500) || "";
77 }
78
79 // Sync carry
80 let carryLog = "already synced";
81 try {
82 const out = execSync("git -C /data/carry pull origin main 2>&1", { timeout: 30000, encoding: "utf-8" });
83 carryLog = out.slice(-200);
84 } catch (e: any) {
85 carryLog = e.message?.slice(-200) || "pull failed";
86 }
87
88 res.writeHead(200, { "Content-Type": "application/json" });
89 res.end(JSON.stringify({ ok: true, vaultLog, carryLog }));
90 } catch (e: any) {
91 res.writeHead(500, { "Content-Type": "application/json" });
92 res.end(JSON.stringify({ error: e.message }));
93 }
94 return;
95 }
96
97 // ── Derive Islands ──
98 // Input: { connections: Array<{source, target, connectionType, note, did, rkey}>, seed?: string }
99 // - With seed: derive single island containing that vertex
100 // - Without seed: detect all islands (all connected components)
101 // Output: { islands: Array<{id, lexmin, vertices, edges}> }
102 if (url.pathname === "/deriveIsland" && req.method === "POST") {
103 const chunks: Buffer[] = [];
104 for await (const chunk of req) chunks.push(chunk);
105 const body = Buffer.concat(chunks).toString();
106
107 try {
108 const input = JSON.parse(body);
109 const { connections, seed } = input;
110
111 if (!Array.isArray(connections)) {
112 res.writeHead(400, { "Content-Type": "application/json" });
113 res.end(JSON.stringify({ error: "connections[] required" }));
114 return;
115 }
116
117 // Build adjacency list
118 const adj = new Map<string, Array<typeof connections[0]>>();
119 const allVertices = new Set<string>();
120 for (const conn of connections) {
121 const s = conn.source as string;
122 const t = conn.target as string;
123 if (!adj.has(s)) adj.set(s, []);
124 if (!adj.has(t)) adj.set(t, []);
125 adj.get(s)!.push(conn);
126 adj.get(t)!.push(conn);
127 allVertices.add(s);
128 allVertices.add(t);
129 }
130
131 // BFS from a seed vertex to find one connected component
132 function bfsComponent(start: string): {
133 vertices: string[];
134 edges: any[];
135 } {
136 const visited = new Set<string>();
137 const queue = [start];
138 const componentEdges: any[] = [];
139
140 while (queue.length > 0) {
141 const current = queue.shift()!;
142 if (visited.has(current)) continue;
143 visited.add(current);
144
145 for (const conn of adj.get(current) || []) {
146 const s = conn.source as string;
147 const t = conn.target as string;
148 componentEdges.push({
149 uri: `at://${conn.did}/network.cosmik.connection/${conn.rkey}`,
150 did: conn.did,
151 rkey: conn.rkey,
152 source: s,
153 target: t,
154 connectionType: conn.connectionType || "relates",
155 note: conn.note || "",
156 });
157 if (!visited.has(s)) queue.push(s);
158 if (!visited.has(t)) queue.push(t);
159 }
160 }
161
162 // Dedupe edges
163 const vertexSet = new Set(visited);
164 const edges = componentEdges.filter(e => vertexSet.has(e.source) && vertexSet.has(e.target));
165 const seenEdges = new Set<string>();
166 const dedupedEdges = edges.filter(e => {
167 const key = `${e.source}|${e.target}|${e.rkey}`;
168 if (seenEdges.has(key)) return false;
169 seenEdges.add(key);
170 return true;
171 });
172
173 const sortedVertices = [...visited].sort();
174 return { vertices: sortedVertices, edges: dedupedEdges };
175 }
176
177 const { createHash } = await import("crypto");
178
179 if (seed) {
180 // Single island from seed vertex
181 const { vertices, edges } = bfsComponent(seed);
182 const lexmin = vertices[0];
183 const id = createHash("sha256").update(lexmin).digest("hex").slice(0, 16);
184 res.writeHead(200, { "Content-Type": "application/json" });
185 res.end(JSON.stringify({ islands: [{ id, lexmin, vertices, edges }] }));
186 } else {
187 // All islands — find all connected components
188 const globalVisited = new Set<string>();
189 const islands: any[] = [];
190
191 for (const vertex of allVertices) {
192 if (globalVisited.has(vertex)) continue;
193 const { vertices, edges } = bfsComponent(vertex);
194 for (const v of vertices) globalVisited.add(v);
195 const lexmin = vertices[0];
196 const id = createHash("sha256").update(lexmin).digest("hex").slice(0, 16);
197 islands.push({ id, lexmin, vertices, edges });
198 }
199
200 // Sort by vertex count descending
201 islands.sort((a, b) => b.vertices.length - a.vertices.length);
202
203 res.writeHead(200, { "Content-Type": "application/json" });
204 res.end(JSON.stringify({ islands }));
205 }
206 } catch (e: any) {
207 console.error("deriveIsland error:", e);
208 res.writeHead(500, { "Content-Type": "application/json" });
209 res.end(JSON.stringify({ error: e.message }));
210 }
211 return;
212 }
213
214 // ── Analyze ──
215 if (url.pathname === "/analyze" && req.method === "POST") {
216 const chunks: Buffer[] = [];
217 for await (const chunk of req) chunks.push(chunk);
218 const body = Buffer.concat(chunks).toString();
219
220 try {
221 const input = JSON.parse(body);
222 const result = await analyze(input);
223 res.writeHead(200, { "Content-Type": "application/json" });
224 res.end(JSON.stringify(result));
225 } catch (e: any) {
226 console.error("Analysis error:", e);
227 res.writeHead(500, { "Content-Type": "application/json" });
228 res.end(JSON.stringify({ error: e.message }));
229 }
230 return;
231 }
232
233 res.writeHead(404);
234 res.end("Not found");
235}).listen(PORT, () => {
236 console.log(`Strata analysis container listening on port ${PORT}`);
237 // Sync on startup
238 syncAll().catch(e => console.error("Initial sync failed:", e));
239});