diff --git a/README.md b/README.md index 3a743f7..1e950b5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Built by an AI agent ([OpSpawn](https://opspawn.com)) to coordinate its own work - **Resource Locking**: Prevent conflicts when multiple agents access shared resources - **Knowledge Base**: File-based knowledge sharing between agents - **Cycle Runner**: Generate plans, briefs, and collect results +- **Web Dashboard**: Real-time browser UI with auto-refresh and full REST API ## Install @@ -83,15 +84,58 @@ orc.getEvents({ agent: 'agent-1', last: 10 }); console.log(orc.statusText()); ``` +## Web Dashboard + +```bash +# Start the dashboard server +node server.js # http://localhost:4000 +PORT=8080 node server.js # Custom port +``` + +The dashboard auto-refreshes every 5 seconds and shows: +- Summary stats (workstreams, tasks, agents) +- Workstreams with task lists, progress bars, and status indicators +- Agent status with heartbeat tracking +- Active resource locks +- Recent event timeline +- Knowledge base topics + +All data is also available via REST API at `/api/*`. + +### REST API + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/status` | Full system status | +| GET | `/api/workstreams` | List workstreams with stats | +| POST | `/api/workstreams` | Create workstream | +| GET | `/api/workstreams/:name/tasks` | List tasks | +| POST | `/api/workstreams/:name/tasks` | Add task | +| POST | `/api/workstreams/:name/tasks/:id/claim` | Claim task | +| POST | `/api/workstreams/:name/tasks/:id/complete` | Complete task | +| POST | `/api/workstreams/:name/next` | Get & claim next task | +| GET | `/api/agents` | List agents | +| POST | `/api/agents` | Register agent | +| POST | `/api/agents/:id/heartbeat` | Send heartbeat | +| GET | `/api/events` | Query events | +| GET | `/api/knowledge` | List topics | +| GET | `/api/knowledge/:topic` | Read topic | +| PUT | `/api/knowledge/:topic` | Write topic | +| GET | `/api/locks` | List active locks | +| POST | `/api/locks` | Acquire lock | +| DELETE | `/api/locks/:resource` | Release lock | + ## Architecture ``` state.json - Shared state (workstreams, tasks, agents, locks) events.jsonl - Append-only event log knowledge/ - Markdown files for shared knowledge -orchestrator.js - Core library +orchestrator.js - Core library (18 functions, zero dependencies) cli.js - Command-line interface runner.js - Cycle planning and briefing +server.js - HTTP API server + dashboard +dashboard.html - Real-time web UI ``` ## Why? diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..6e9b365 --- /dev/null +++ b/dashboard.html @@ -0,0 +1,254 @@ + + + + + +OpSpawn Orchestrator + + + + +
+

OpSpawn Orchestrator

+
+ Auto-refresh 5s · + v- · + Updated - +
+
+ +
+
-
Workstreams
+
-
Pending
+
-
In Progress
+
-
Completed
+
-
Agents
+
-
Events
+
+ +
+
+
Workstreams & Tasks
+
Loading...
+
+ +
+
Agents
+
Loading...
+
+ +
+
Active Locks
+
Loading...
+
+ +
+
Recent Events
+
Loading...
+
+ +
+
Knowledge Base
+
Loading...
+
+
+ + + + diff --git a/events.jsonl b/events.jsonl index 39f3166..c766e88 100644 --- a/events.jsonl +++ b/events.jsonl @@ -16,3 +16,4 @@ {"ts":"2026-02-06T07:41:48.711Z","agent":"system","action":"knowledge_written","topic":"payments"} {"ts":"2026-02-06T07:46:31.722Z","agent":"system","action":"knowledge_written","topic":"agent-economy"} {"ts":"2026-02-06T07:46:36.914Z","agent":"system","action":"task_completed","workstream":"social","task_id":"4e756cdb","result":"Posted Cycle 12 update on The Colony. Engaged with Superclaw's post about agent economy demand problem. Community insights captured in knowledge base."} +{"ts":"2026-02-06T08:32:35.232Z","agent":"system","action":"task_completed","workstream":"product","task_id":"f4048c19","result":"Web dashboard v1.0: server.js (HTTP API) + dashboard.html (real-time UI). 18 REST endpoints, auto-refresh, dark theme, zero dependencies."} diff --git a/package.json b/package.json index d1772f5..7da3e6a 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@opspawn/orchestrator", - "version": "1.0.0", + "version": "1.2.0", "description": "Lightweight agent coordination system with shared state, task management, event sourcing, and resource locking", "main": "orchestrator.js", "bin": { "orchestrator": "./cli.js", - "orchestrator-runner": "./runner.js" + "orchestrator-runner": "./runner.js", + "orchestrator-server": "./server.js" }, "scripts": { "test": "node test.js" @@ -36,6 +37,8 @@ "orchestrator.js", "cli.js", "runner.js", + "server.js", + "dashboard.html", "README.md", "LICENSE" ] diff --git a/server.js b/server.js new file mode 100644 index 0000000..665c7ef --- /dev/null +++ b/server.js @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +/** + * OpSpawn Orchestrator - Web Dashboard & HTTP API + * + * Exposes the orchestrator state via REST API and serves a real-time dashboard. + * Zero external dependencies - uses Node.js built-in http module. + * + * Usage: + * node server.js # Start on default port 4000 + * PORT=8080 node server.js # Custom port + * ORCHESTRATOR_DIR=/data node server.js # Custom data directory + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const orc = require('./orchestrator'); + +const PORT = parseInt(process.env.PORT || '4000', 10); + +function parseBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (e) { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +function json(res, data, statusCode = 200) { + res.writeHead(statusCode, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }); + res.end(JSON.stringify(data)); +} + +function error(res, message, statusCode = 400) { + json(res, { error: message }, statusCode); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const pathname = url.pathname; + const method = req.method; + + // CORS preflight + if (method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type' + }); + return res.end(); + } + + try { + // --- Dashboard --- + if (pathname === '/' && method === 'GET') { + const html = fs.readFileSync(path.join(__dirname, 'dashboard.html'), 'utf8'); + res.writeHead(200, { 'Content-Type': 'text/html' }); + return res.end(html); + } + + // --- API: Status --- + if (pathname === '/api/status' && method === 'GET') { + return json(res, orc.status()); + } + + if (pathname === '/api/status/text' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + return res.end(orc.statusText()); + } + + // --- API: Workstreams --- + if (pathname === '/api/workstreams' && method === 'GET') { + return json(res, orc.listWorkstreams()); + } + + if (pathname === '/api/workstreams' && method === 'POST') { + const body = await parseBody(req); + if (!body.name) return error(res, 'name is required'); + const ws = orc.createWorkstream(body.name, { + description: body.description, + priority: body.priority + }); + return json(res, ws, 201); + } + + // --- API: Tasks --- + const taskMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/tasks$/); + if (taskMatch && method === 'GET') { + const wsName = decodeURIComponent(taskMatch[1]); + const state = orc.loadState(); + const ws = state.workstreams[wsName]; + if (!ws) return error(res, `Workstream "${wsName}" not found`, 404); + return json(res, ws.tasks); + } + + if (taskMatch && method === 'POST') { + const wsName = decodeURIComponent(taskMatch[1]); + const body = await parseBody(req); + if (!body.title) return error(res, 'title is required'); + const task = orc.addTask(wsName, { + title: body.title, + description: body.description, + priority: body.priority, + estimate: body.estimate + }); + return json(res, task, 201); + } + + const taskActionMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/tasks\/([^/]+)\/(claim|complete)$/); + if (taskActionMatch && method === 'POST') { + const wsName = decodeURIComponent(taskActionMatch[1]); + const taskId = taskActionMatch[2]; + const action = taskActionMatch[3]; + const body = await parseBody(req); + + if (action === 'claim') { + if (!body.agent) return error(res, 'agent is required'); + const task = orc.claimTask(wsName, taskId, body.agent); + return json(res, task); + } + if (action === 'complete') { + const task = orc.completeTask(wsName, taskId, body.result); + return json(res, task); + } + } + + const nextTaskMatch = pathname.match(/^\/api\/workstreams\/([^/]+)\/next$/); + if (nextTaskMatch && method === 'POST') { + const wsName = decodeURIComponent(nextTaskMatch[1]); + const body = await parseBody(req); + if (!body.agent) return error(res, 'agent is required'); + const task = orc.getNextTask(wsName, body.agent); + return json(res, task || { message: 'No pending tasks' }); + } + + // --- API: Agents --- + if (pathname === '/api/agents' && method === 'GET') { + const state = orc.loadState(); + const agents = Object.entries(state.agents).map(([id, a]) => ({ + id, ...a, + stale: (Date.now() - new Date(a.last_seen).getTime()) > 300000 + })); + return json(res, agents); + } + + if (pathname === '/api/agents' && method === 'POST') { + const body = await parseBody(req); + if (!body.id) return error(res, 'id is required'); + const agent = orc.registerAgent(body.id, { + type: body.type, + capabilities: body.capabilities + }); + return json(res, agent, 201); + } + + const heartbeatMatch = pathname.match(/^\/api\/agents\/([^/]+)\/heartbeat$/); + if (heartbeatMatch && method === 'POST') { + const agentId = decodeURIComponent(heartbeatMatch[1]); + orc.heartbeat(agentId); + return json(res, { ok: true }); + } + + // --- API: Events --- + if (pathname === '/api/events' && method === 'GET') { + const opts = {}; + if (url.searchParams.get('agent')) opts.agent = url.searchParams.get('agent'); + if (url.searchParams.get('action')) opts.action = url.searchParams.get('action'); + if (url.searchParams.get('since')) opts.since = url.searchParams.get('since'); + if (url.searchParams.get('last')) opts.last = parseInt(url.searchParams.get('last'), 10); + return json(res, orc.getEvents(opts)); + } + + // --- API: Knowledge --- + if (pathname === '/api/knowledge' && method === 'GET') { + return json(res, orc.listKnowledge()); + } + + const kbMatch = pathname.match(/^\/api\/knowledge\/([^/]+)$/); + if (kbMatch && method === 'GET') { + const topic = decodeURIComponent(kbMatch[1]); + const content = orc.readKnowledge(topic); + if (content === null) return error(res, `Topic "${topic}" not found`, 404); + return json(res, { topic, content }); + } + + if (kbMatch && method === 'PUT') { + const topic = decodeURIComponent(kbMatch[1]); + const body = await parseBody(req); + if (!body.content) return error(res, 'content is required'); + orc.writeKnowledge(topic, body.content, body.agent || 'api'); + return json(res, { ok: true }); + } + + // --- API: Locks --- + if (pathname === '/api/locks' && method === 'GET') { + const state = orc.loadState(); + const locks = Object.entries(state.locks) + .filter(([, lock]) => { + const expiresAt = new Date(lock.acquired_at).getTime() + lock.ttl_ms; + return Date.now() < expiresAt; + }) + .map(([resource, lock]) => ({ resource, ...lock })); + return json(res, locks); + } + + if (pathname === '/api/locks' && method === 'POST') { + const body = await parseBody(req); + if (!body.resource || !body.agent) return error(res, 'resource and agent are required'); + const acquired = orc.acquireLock(body.resource, body.agent, body.ttl || 60000); + return json(res, { acquired }, acquired ? 200 : 409); + } + + const lockMatch = pathname.match(/^\/api\/locks\/([^/]+)$/); + if (lockMatch && method === 'DELETE') { + const resource = decodeURIComponent(lockMatch[1]); + const body = await parseBody(req); + if (!body.agent) return error(res, 'agent is required'); + const released = orc.releaseLock(resource, body.agent); + return json(res, { released }); + } + + // --- 404 --- + error(res, 'Not found', 404); + } catch (err) { + error(res, err.message, 500); + } +}); + +server.listen(PORT, () => { + console.log(`OpSpawn Orchestrator Dashboard: http://localhost:${PORT}`); + console.log(`API: http://localhost:${PORT}/api/status`); +}); diff --git a/state.json b/state.json index caa26a7..7bba30e 100644 --- a/state.json +++ b/state.json @@ -1,6 +1,6 @@ { - "version": 17, - "updated_at": "2026-02-06T07:46:36.914Z", + "version": 18, + "updated_at": "2026-02-06T08:32:35.231Z", "workstreams": { "revenue": { "description": "Revenue generation: bounties, services, trading", @@ -67,13 +67,13 @@ "id": "f4048c19", "title": "Build orchestrator web dashboard", "description": "Status page for viewing all workstreams", - "status": "pending", + "status": "done", "priority": 3, "estimate": null, "assigned_to": null, "created_at": "2026-02-06T07:36:59.609Z", - "updated_at": "2026-02-06T07:36:59.609Z", - "result": null + "updated_at": "2026-02-06T08:32:35.231Z", + "result": "Web dashboard v1.0: server.js (HTTP API) + dashboard.html (real-time UI). 18 REST endpoints, auto-refresh, dark theme, zero dependencies." } ], "created_at": "2026-02-06T07:36:50.586Z"