Orchestrator v1.2: Web dashboard and HTTP API
Add real-time web dashboard (dashboard.html) and HTTP API server (server.js). 18 REST endpoints expose all orchestrator operations. Dashboard auto-refreshes every 5 seconds with dark theme, summary stats, workstream progress bars, task lists, agent status, event timeline, and knowledge base view. Zero external dependencies - uses Node.js built-in http module. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4dd8ea3bac
commit
9b7816d5d5
46
README.md
46
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
|
- **Resource Locking**: Prevent conflicts when multiple agents access shared resources
|
||||||
- **Knowledge Base**: File-based knowledge sharing between agents
|
- **Knowledge Base**: File-based knowledge sharing between agents
|
||||||
- **Cycle Runner**: Generate plans, briefs, and collect results
|
- **Cycle Runner**: Generate plans, briefs, and collect results
|
||||||
|
- **Web Dashboard**: Real-time browser UI with auto-refresh and full REST API
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@ -83,15 +84,58 @@ orc.getEvents({ agent: 'agent-1', last: 10 });
|
|||||||
console.log(orc.statusText());
|
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
state.json - Shared state (workstreams, tasks, agents, locks)
|
state.json - Shared state (workstreams, tasks, agents, locks)
|
||||||
events.jsonl - Append-only event log
|
events.jsonl - Append-only event log
|
||||||
knowledge/ - Markdown files for shared knowledge
|
knowledge/ - Markdown files for shared knowledge
|
||||||
orchestrator.js - Core library
|
orchestrator.js - Core library (18 functions, zero dependencies)
|
||||||
cli.js - Command-line interface
|
cli.js - Command-line interface
|
||||||
runner.js - Cycle planning and briefing
|
runner.js - Cycle planning and briefing
|
||||||
|
server.js - HTTP API server + dashboard
|
||||||
|
dashboard.html - Real-time web UI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|||||||
254
dashboard.html
Normal file
254
dashboard.html
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OpSpawn Orchestrator</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
||||||
|
--text: #e6edf3; --muted: #7d8590; --accent: #58a6ff;
|
||||||
|
--green: #3fb950; --yellow: #d29922; --red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
.header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.header h1 { font-size: 20px; font-weight: 600; }
|
||||||
|
.header h1 span { color: var(--accent); }
|
||||||
|
.meta { color: var(--muted); font-size: 13px; }
|
||||||
|
.meta .live { color: var(--green); }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 24px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
.card-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card-body { padding: 16px; }
|
||||||
|
.card.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
.ws { margin-bottom: 16px; }
|
||||||
|
.ws:last-child { margin-bottom: 0; }
|
||||||
|
.ws-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; cursor: pointer; }
|
||||||
|
.ws-name { font-weight: 600; font-size: 15px; }
|
||||||
|
.ws-priority { background: var(--accent); color: #000; font-size: 11px; font-weight: 700; padding: 1px 6px; border-radius: 10px; }
|
||||||
|
.ws-stats { color: var(--muted); font-size: 13px; margin-left: auto; }
|
||||||
|
|
||||||
|
.progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 8px; }
|
||||||
|
.progress-fill { height: 100%; background: var(--green); transition: width 0.3s; }
|
||||||
|
|
||||||
|
.task-list { list-style: none; }
|
||||||
|
.task { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
||||||
|
.task:last-child { border-bottom: none; }
|
||||||
|
.task-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.task-status.pending { background: var(--muted); }
|
||||||
|
.task-status.in_progress { background: var(--yellow); }
|
||||||
|
.task-status.done { background: var(--green); }
|
||||||
|
.task-title { flex: 1; }
|
||||||
|
.task-title.done { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.task-agent { color: var(--purple); font-size: 12px; }
|
||||||
|
.task-id { color: var(--muted); font-size: 11px; font-family: monospace; }
|
||||||
|
|
||||||
|
.agent-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.agent-row:last-child { border-bottom: none; }
|
||||||
|
.agent-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||||
|
.agent-dot.active { background: var(--green); }
|
||||||
|
.agent-dot.stale { background: var(--red); }
|
||||||
|
.agent-name { font-weight: 500; font-size: 14px; }
|
||||||
|
.agent-type { color: var(--muted); font-size: 12px; }
|
||||||
|
.agent-seen { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||||
|
|
||||||
|
.event-row { display: flex; gap: 8px; padding: 4px 0; font-size: 13px; font-family: monospace; border-bottom: 1px solid var(--border); }
|
||||||
|
.event-row:last-child { border-bottom: none; }
|
||||||
|
.event-time { color: var(--muted); white-space: nowrap; }
|
||||||
|
.event-agent { color: var(--purple); min-width: 60px; }
|
||||||
|
.event-action { color: var(--accent); }
|
||||||
|
|
||||||
|
.kb-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.kb-tag { background: var(--border); color: var(--text); padding: 4px 12px; border-radius: 16px; font-size: 13px; }
|
||||||
|
|
||||||
|
.lock-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
|
||||||
|
.lock-row:last-child { border-bottom: none; }
|
||||||
|
.lock-icon { color: var(--yellow); }
|
||||||
|
|
||||||
|
.empty { color: var(--muted); font-size: 13px; font-style: italic; }
|
||||||
|
|
||||||
|
.summary-row { display: flex; gap: 24px; padding: 16px 24px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.summary-stat { text-align: center; }
|
||||||
|
.summary-stat .num { font-size: 28px; font-weight: 700; }
|
||||||
|
.summary-stat .label { font-size: 12px; color: var(--muted); text-transform: uppercase; }
|
||||||
|
.summary-stat .num.green { color: var(--green); }
|
||||||
|
.summary-stat .num.yellow { color: var(--yellow); }
|
||||||
|
.summary-stat .num.muted { color: var(--muted); }
|
||||||
|
.summary-stat .num.accent { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1><span>◆</span> OpSpawn Orchestrator</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="live">●</span> Auto-refresh 5s ·
|
||||||
|
v<span id="version">-</span> ·
|
||||||
|
Updated <span id="updated">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="summary-stat"><div class="num accent" id="s-workstreams">-</div><div class="label">Workstreams</div></div>
|
||||||
|
<div class="summary-stat"><div class="num muted" id="s-pending">-</div><div class="label">Pending</div></div>
|
||||||
|
<div class="summary-stat"><div class="num yellow" id="s-active">-</div><div class="label">In Progress</div></div>
|
||||||
|
<div class="summary-stat"><div class="num green" id="s-done">-</div><div class="label">Completed</div></div>
|
||||||
|
<div class="summary-stat"><div class="num accent" id="s-agents">-</div><div class="label">Agents</div></div>
|
||||||
|
<div class="summary-stat"><div class="num" id="s-events">-</div><div class="label">Events</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card full">
|
||||||
|
<div class="card-header">Workstreams & Tasks</div>
|
||||||
|
<div class="card-body" id="workstreams"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Agents</div>
|
||||||
|
<div class="card-body" id="agents"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Active Locks</div>
|
||||||
|
<div class="card-body" id="locks"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card full">
|
||||||
|
<div class="card-header">Recent Events</div>
|
||||||
|
<div class="card-body" id="events"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Knowledge Base</div>
|
||||||
|
<div class="card-body" id="knowledge"><div class="empty">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
if (!res.ok) throw new Error('fetch failed');
|
||||||
|
const d = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('version').textContent = d.version;
|
||||||
|
document.getElementById('updated').textContent = timeAgo(d.updated_at);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
let totalPending = 0, totalActive = 0, totalDone = 0;
|
||||||
|
d.workstreams.forEach(ws => { totalPending += ws.pending; totalActive += ws.in_progress; totalDone += ws.done; });
|
||||||
|
document.getElementById('s-workstreams').textContent = d.workstreams.length;
|
||||||
|
document.getElementById('s-pending').textContent = totalPending;
|
||||||
|
document.getElementById('s-active').textContent = totalActive;
|
||||||
|
document.getElementById('s-done').textContent = totalDone;
|
||||||
|
document.getElementById('s-agents').textContent = d.agents.length;
|
||||||
|
document.getElementById('s-events').textContent = d.recent_events.length;
|
||||||
|
|
||||||
|
// Workstreams
|
||||||
|
const wsEl = document.getElementById('workstreams');
|
||||||
|
if (d.workstreams.length === 0) {
|
||||||
|
wsEl.innerHTML = '<div class="empty">No workstreams configured</div>';
|
||||||
|
} else {
|
||||||
|
wsEl.innerHTML = d.workstreams.map(ws => {
|
||||||
|
const total = ws.task_count || 1;
|
||||||
|
const pct = Math.round((ws.done / total) * 100);
|
||||||
|
const tasks = (ws.tasks || []).sort((a, b) => {
|
||||||
|
const order = { in_progress: 0, pending: 1, done: 2 };
|
||||||
|
return (order[a.status] ?? 1) - (order[b.status] ?? 1) || a.priority - b.priority;
|
||||||
|
});
|
||||||
|
return `<div class="ws">
|
||||||
|
<div class="ws-header">
|
||||||
|
<span class="ws-priority">P${ws.priority}</span>
|
||||||
|
<span class="ws-name">${esc(ws.name)}</span>
|
||||||
|
${ws.description ? `<span style="color:var(--muted);font-size:13px">${esc(ws.description)}</span>` : ''}
|
||||||
|
<span class="ws-stats">${ws.pending}p / ${ws.in_progress}a / ${ws.done}d</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
|
||||||
|
<ul class="task-list">${tasks.map(t => `<li class="task">
|
||||||
|
<span class="task-status ${t.status}"></span>
|
||||||
|
<span class="task-title ${t.status === 'done' ? 'done' : ''}">${esc(t.title)}</span>
|
||||||
|
${t.assigned_to ? `<span class="task-agent">${esc(t.assigned_to)}</span>` : ''}
|
||||||
|
<span class="task-id">${t.id}</span>
|
||||||
|
</li>`).join('')}</ul>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
const agEl = document.getElementById('agents');
|
||||||
|
if (d.agents.length === 0) {
|
||||||
|
agEl.innerHTML = '<div class="empty">No agents registered</div>';
|
||||||
|
} else {
|
||||||
|
agEl.innerHTML = d.agents.map(a => `<div class="agent-row">
|
||||||
|
<span class="agent-dot ${a.stale ? 'stale' : 'active'}"></span>
|
||||||
|
<span class="agent-name">${esc(a.id)}</span>
|
||||||
|
<span class="agent-type">${esc(a.type)}</span>
|
||||||
|
<span class="agent-seen">${timeAgo(a.last_seen)}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks
|
||||||
|
const lockEl = document.getElementById('locks');
|
||||||
|
if (d.active_locks.length === 0) {
|
||||||
|
lockEl.innerHTML = '<div class="empty">No active locks</div>';
|
||||||
|
} else {
|
||||||
|
lockEl.innerHTML = d.active_locks.map(l => `<div class="lock-row">
|
||||||
|
<span class="lock-icon">🔒</span>
|
||||||
|
<strong>${esc(l.resource)}</strong>
|
||||||
|
<span style="color:var(--muted)">held by</span>
|
||||||
|
<span style="color:var(--purple)">${esc(l.agent)}</span>
|
||||||
|
<span style="color:var(--muted);margin-left:auto">${timeAgo(l.acquired_at)}</span>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const evEl = document.getElementById('events');
|
||||||
|
if (d.recent_events.length === 0) {
|
||||||
|
evEl.innerHTML = '<div class="empty">No events recorded</div>';
|
||||||
|
} else {
|
||||||
|
evEl.innerHTML = [...d.recent_events].reverse().map(e => {
|
||||||
|
const time = e.ts.split('T')[1].split('.')[0];
|
||||||
|
return `<div class="event-row">
|
||||||
|
<span class="event-time">${time}</span>
|
||||||
|
<span class="event-agent">${esc(e.agent)}</span>
|
||||||
|
<span class="event-action">${esc(e.action)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knowledge
|
||||||
|
const kbEl = document.getElementById('knowledge');
|
||||||
|
if (d.knowledge_topics.length === 0) {
|
||||||
|
kbEl.innerHTML = '<div class="empty">No knowledge entries</div>';
|
||||||
|
} else {
|
||||||
|
kbEl.innerHTML = '<div class="kb-list">' +
|
||||||
|
d.knowledge_topics.map(t => `<span class="kb-tag">${esc(t)}</span>`).join('') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Refresh failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
function timeAgo(iso) {
|
||||||
|
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (s < 60) return s + 's ago';
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||||
|
return Math.floor(s / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -16,3 +16,4 @@
|
|||||||
{"ts":"2026-02-06T07:41:48.711Z","agent":"system","action":"knowledge_written","topic":"payments"}
|
{"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: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-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."}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@opspawn/orchestrator",
|
"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",
|
"description": "Lightweight agent coordination system with shared state, task management, event sourcing, and resource locking",
|
||||||
"main": "orchestrator.js",
|
"main": "orchestrator.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
"orchestrator": "./cli.js",
|
"orchestrator": "./cli.js",
|
||||||
"orchestrator-runner": "./runner.js"
|
"orchestrator-runner": "./runner.js",
|
||||||
|
"orchestrator-server": "./server.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node test.js"
|
"test": "node test.js"
|
||||||
@ -36,6 +37,8 @@
|
|||||||
"orchestrator.js",
|
"orchestrator.js",
|
||||||
"cli.js",
|
"cli.js",
|
||||||
"runner.js",
|
"runner.js",
|
||||||
|
"server.js",
|
||||||
|
"dashboard.html",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
]
|
]
|
||||||
|
|||||||
245
server.js
Normal file
245
server.js
Normal file
@ -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`);
|
||||||
|
});
|
||||||
10
state.json
10
state.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 17,
|
"version": 18,
|
||||||
"updated_at": "2026-02-06T07:46:36.914Z",
|
"updated_at": "2026-02-06T08:32:35.231Z",
|
||||||
"workstreams": {
|
"workstreams": {
|
||||||
"revenue": {
|
"revenue": {
|
||||||
"description": "Revenue generation: bounties, services, trading",
|
"description": "Revenue generation: bounties, services, trading",
|
||||||
@ -67,13 +67,13 @@
|
|||||||
"id": "f4048c19",
|
"id": "f4048c19",
|
||||||
"title": "Build orchestrator web dashboard",
|
"title": "Build orchestrator web dashboard",
|
||||||
"description": "Status page for viewing all workstreams",
|
"description": "Status page for viewing all workstreams",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"priority": 3,
|
"priority": 3,
|
||||||
"estimate": null,
|
"estimate": null,
|
||||||
"assigned_to": null,
|
"assigned_to": null,
|
||||||
"created_at": "2026-02-06T07:36:59.609Z",
|
"created_at": "2026-02-06T07:36:59.609Z",
|
||||||
"updated_at": "2026-02-06T07:36:59.609Z",
|
"updated_at": "2026-02-06T08:32:35.231Z",
|
||||||
"result": null
|
"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"
|
"created_at": "2026-02-06T07:36:50.586Z"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user