1169 lines
36 KiB
JavaScript
1169 lines
36 KiB
JavaScript
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const sqlite3 = require('sqlite3').verbose();
|
|
const { WebSocketServer } = require('ws');
|
|
const { setupGiteaRoutes } = require('./gitea-routes.js');
|
|
|
|
const PORT = process.env.PORT || 8395;
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
|
|
const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki';
|
|
|
|
const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
|
|
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
|
|
const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
|
|
const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical'];
|
|
|
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
|
|
|
const db = new sqlite3.Database(DB_PATH);
|
|
|
|
db.serialize(() => {
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
assignee TEXT DEFAULT '',
|
|
priority TEXT NOT NULL DEFAULT 'Medium',
|
|
status TEXT NOT NULL DEFAULT 'Backlog',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT
|
|
)
|
|
`);
|
|
|
|
// Usage tracking table
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS usage_tracking (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
request_type TEXT DEFAULT 'chat',
|
|
tokens_used INTEGER DEFAULT 0,
|
|
cost_estimate REAL DEFAULT 0,
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
`);
|
|
});
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocketServer({ server });
|
|
const VIEWS_DIR = path.join(__dirname, 'views');
|
|
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
|
|
|
function renderTemplate(template, vars = {}) {
|
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
const value = vars[key];
|
|
return value === undefined || value === null ? '' : String(value);
|
|
});
|
|
}
|
|
|
|
function renderPage(viewName, activeTab, pageTitle) {
|
|
const layoutPath = path.join(VIEWS_DIR, 'layout.html');
|
|
const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
|
|
const layout = fs.readFileSync(layoutPath, 'utf8');
|
|
const content = fs.readFileSync(viewPath, 'utf8');
|
|
|
|
return renderTemplate(layout, {
|
|
pageTitle,
|
|
pageName: viewName,
|
|
content,
|
|
tasksActive: activeTab === 'tasks' ? 'active' : '',
|
|
wikiActive: activeTab === 'wiki' ? 'active' : '',
|
|
agentsActive: activeTab === 'agents' ? 'active' : '',
|
|
usageActive: activeTab === 'usage' ? 'active' : '',
|
|
giteaActive: activeTab === 'gitea' ? 'active' : '',
|
|
markedScript: viewName === 'wiki'
|
|
? '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
|
|
: '',
|
|
chartScript: viewName === 'usage'
|
|
? '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
|
|
: '',
|
|
});
|
|
}
|
|
|
|
// ============ SERVER-RENDERED PAGES ============
|
|
|
|
app.get('/', (req, res) => {
|
|
res.redirect('/tasks');
|
|
});
|
|
|
|
app.get('/tasks', (req, res) => {
|
|
res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks'));
|
|
});
|
|
|
|
app.get('/wiki', (req, res) => {
|
|
res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki'));
|
|
});
|
|
|
|
app.get('/agents', (req, res) => {
|
|
res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents'));
|
|
});
|
|
|
|
app.get('/usage', (req, res) => {
|
|
res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage'));
|
|
});
|
|
|
|
app.get('/gitea', (req, res) => {
|
|
res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
|
|
});
|
|
|
|
function normalizeTask(row) {
|
|
return {
|
|
...row,
|
|
tags: (() => {
|
|
try {
|
|
return JSON.parse(row.tags || '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
})(),
|
|
};
|
|
}
|
|
|
|
function writeWiki(task) {
|
|
const safeTitle = task.title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80) || `task-${task.id}`;
|
|
|
|
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`;
|
|
const filePath = path.join(WIKI_DIR, fileName);
|
|
|
|
const md = `# ${task.title}\n\n` +
|
|
`- Task ID: ${task.id}\n` +
|
|
`- Assignee: ${task.assignee || 'Unassigned'}\n` +
|
|
`- Priority: ${task.priority}\n` +
|
|
`- Status: ${task.status}\n` +
|
|
`- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` +
|
|
`- Created: ${task.created_at}\n` +
|
|
`- Completed: ${task.completed_at || new Date().toISOString()}\n\n` +
|
|
`## Description\n\n${task.description || 'No description provided.'}\n`;
|
|
|
|
fs.writeFileSync(filePath, md, 'utf8');
|
|
}
|
|
|
|
function broadcast(type, payload) {
|
|
const data = JSON.stringify({ type, payload });
|
|
for (const client of wss.clients) {
|
|
if (client.readyState === 1) {
|
|
client.send(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validatePayload(body, partial = false) {
|
|
const errors = [];
|
|
|
|
if (!partial || body.title !== undefined) {
|
|
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
|
|
errors.push('title is required');
|
|
}
|
|
}
|
|
|
|
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
|
|
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
|
|
}
|
|
|
|
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
|
|
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
|
|
}
|
|
|
|
if (body.tags !== undefined && !Array.isArray(body.tags)) {
|
|
errors.push('tags must be an array of strings');
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
// ============ TASKS API ============
|
|
|
|
app.get('/api/tasks', (req, res) => {
|
|
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
|
|
}
|
|
|
|
return res.json(rows.map(normalizeTask));
|
|
});
|
|
});
|
|
|
|
app.post('/api/tasks', (req, res) => {
|
|
const errors = validatePayload(req.body, false);
|
|
if (errors.length) {
|
|
return res.status(400).json({ error: 'validation_error', details: errors });
|
|
}
|
|
|
|
const title = req.body.title.trim();
|
|
const description = typeof req.body.description === 'string' ? req.body.description : '';
|
|
const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : '';
|
|
const priority = req.body.priority || 'Medium';
|
|
const status = req.body.status || 'Backlog';
|
|
const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : [];
|
|
|
|
db.run(
|
|
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[title, description, assignee, priority, status, JSON.stringify(tags)],
|
|
function onInsert(err) {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'failed_to_create_task' });
|
|
}
|
|
|
|
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => {
|
|
if (fetchErr || !row) {
|
|
return res.status(500).json({ error: 'failed_to_fetch_created_task' });
|
|
}
|
|
|
|
const task = normalizeTask(row);
|
|
broadcast('task_created', task);
|
|
return res.status(201).json(task);
|
|
});
|
|
}
|
|
);
|
|
});
|
|
|
|
app.patch('/api/tasks/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
return res.status(400).json({ error: 'invalid_task_id' });
|
|
}
|
|
|
|
const errors = validatePayload(req.body, true);
|
|
if (errors.length) {
|
|
return res.status(400).json({ error: 'validation_error', details: errors });
|
|
}
|
|
|
|
db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'failed_to_find_task' });
|
|
}
|
|
if (!existing) {
|
|
return res.status(404).json({ error: 'task_not_found' });
|
|
}
|
|
|
|
const existingTask = normalizeTask(existing);
|
|
const next = {
|
|
title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title,
|
|
description: req.body.description !== undefined ? String(req.body.description) : existingTask.description,
|
|
assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee,
|
|
priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority,
|
|
status: req.body.status !== undefined ? req.body.status : existingTask.status,
|
|
tags: req.body.tags !== undefined
|
|
? req.body.tags.filter((t) => typeof t === 'string')
|
|
: existingTask.tags,
|
|
};
|
|
|
|
const nowDone = next.status === 'Done';
|
|
const wasDone = existingTask.status === 'Done';
|
|
const completedAt = nowDone && !wasDone
|
|
? new Date().toISOString()
|
|
: nowDone
|
|
? existing.completed_at
|
|
: null;
|
|
|
|
db.run(
|
|
`UPDATE tasks
|
|
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
|
|
completed_at = ?, updated_at = datetime('now')
|
|
WHERE id = ?`,
|
|
[
|
|
next.title,
|
|
next.description,
|
|
next.assignee,
|
|
next.priority,
|
|
next.status,
|
|
JSON.stringify(next.tags),
|
|
completedAt,
|
|
id,
|
|
],
|
|
(updateErr) => {
|
|
if (updateErr) {
|
|
return res.status(500).json({ error: 'failed_to_update_task' });
|
|
}
|
|
|
|
db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => {
|
|
if (fetchErr || !row) {
|
|
return res.status(500).json({ error: 'failed_to_fetch_updated_task' });
|
|
}
|
|
|
|
const task = normalizeTask(row);
|
|
|
|
if (nowDone && !wasDone) {
|
|
try {
|
|
writeWiki(task);
|
|
} catch (wikiErr) {
|
|
console.error('wiki_creation_error', wikiErr);
|
|
}
|
|
}
|
|
|
|
broadcast('task_updated', task);
|
|
return res.json(task);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// ============ WIKI API ============
|
|
|
|
// Helper to extract frontmatter metadata from markdown
|
|
function extractMetadata(content) {
|
|
const metadata = {
|
|
title: '',
|
|
created: null,
|
|
modified: null,
|
|
tags: []
|
|
};
|
|
|
|
// Check for YAML-like frontmatter
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (frontmatterMatch) {
|
|
const frontmatter = frontmatterMatch[1];
|
|
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
|
|
const createdMatch = frontmatter.match(/created:\s*(.+)/i);
|
|
const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
|
|
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
|
|
|
|
if (titleMatch) metadata.title = titleMatch[1].trim();
|
|
if (createdMatch) metadata.created = createdMatch[1].trim();
|
|
if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
|
|
if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
|
|
}
|
|
|
|
// Extract title from first heading if not in frontmatter
|
|
if (!metadata.title) {
|
|
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
if (headingMatch) {
|
|
metadata.title = headingMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
// GET /api/wiki - List all wiki pages
|
|
app.get('/api/wiki', (req, res) => {
|
|
try {
|
|
if (!fs.existsSync(WIKI_DIR)) {
|
|
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
|
return res.json([]);
|
|
}
|
|
|
|
const files = fs.readdirSync(WIKI_DIR)
|
|
.filter(f => f.endsWith('.md'))
|
|
.map(filename => {
|
|
const filePath = path.join(WIKI_DIR, filename);
|
|
const stats = fs.statSync(filePath);
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const metadata = extractMetadata(content);
|
|
|
|
return {
|
|
filename,
|
|
title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
|
|
created: stats.birthtime.toISOString(),
|
|
modified: stats.mtime.toISOString(),
|
|
tags: metadata.tags
|
|
};
|
|
})
|
|
.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
|
|
|
res.json(files);
|
|
} catch (err) {
|
|
console.error('Error listing wiki pages:', err);
|
|
res.status(500).json({ error: 'failed_to_list_wiki_pages' });
|
|
}
|
|
});
|
|
|
|
// GET /api/wiki/:filename - Get specific wiki page content
|
|
app.get('/api/wiki/:filename', (req, res) => {
|
|
try {
|
|
const filename = req.params.filename;
|
|
// Security: prevent path traversal
|
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
return res.status(400).json({ error: 'invalid_filename' });
|
|
}
|
|
|
|
const filePath = path.join(WIKI_DIR, filename);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const stats = fs.statSync(filePath);
|
|
const metadata = extractMetadata(content);
|
|
|
|
res.json({
|
|
filename,
|
|
content,
|
|
metadata: {
|
|
...metadata,
|
|
created: stats.birthtime.toISOString(),
|
|
modified: stats.mtime.toISOString()
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Error reading wiki page:', err);
|
|
res.status(500).json({ error: 'failed_to_read_wiki_page' });
|
|
}
|
|
});
|
|
|
|
// POST /api/wiki - Create new wiki page
|
|
app.post('/api/wiki', (req, res) => {
|
|
try {
|
|
const { title, content } = req.body;
|
|
|
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
|
return res.status(400).json({ error: 'title_is_required' });
|
|
}
|
|
|
|
const safeTitle = title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
|
|
const timestamp = new Date().toISOString().slice(0, 10);
|
|
let filename = `${timestamp}-${safeTitle}.md`;
|
|
|
|
// Ensure unique filename
|
|
let counter = 1;
|
|
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
|
|
filename = `${timestamp}-${safeTitle}-${counter}.md`;
|
|
counter++;
|
|
}
|
|
|
|
const filePath = path.join(WIKI_DIR, filename);
|
|
const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
|
|
|
|
fs.writeFileSync(filePath, pageContent, 'utf8');
|
|
|
|
broadcast('wiki_created', { filename, title });
|
|
res.status(201).json({ filename, success: true, title });
|
|
} catch (err) {
|
|
console.error('Error creating wiki page:', err);
|
|
res.status(500).json({ error: 'failed_to_create_wiki_page' });
|
|
}
|
|
});
|
|
|
|
// PUT /api/wiki/:filename - Update wiki page
|
|
app.put('/api/wiki/:filename', (req, res) => {
|
|
try {
|
|
const filename = req.params.filename;
|
|
const { content } = req.body;
|
|
|
|
// Security: prevent path traversal
|
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
return res.status(400).json({ error: 'invalid_filename' });
|
|
}
|
|
|
|
if (typeof content !== 'string') {
|
|
return res.status(400).json({ error: 'content_is_required' });
|
|
}
|
|
|
|
const filePath = path.join(WIKI_DIR, filename);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
|
}
|
|
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
|
|
broadcast('wiki_updated', { filename });
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Error updating wiki page:', err);
|
|
res.status(500).json({ error: 'failed_to_update_wiki_page' });
|
|
}
|
|
});
|
|
|
|
// DELETE /api/wiki/:filename - Delete wiki page
|
|
app.delete('/api/wiki/:filename', (req, res) => {
|
|
try {
|
|
const filename = req.params.filename;
|
|
|
|
// Security: prevent path traversal
|
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
return res.status(400).json({ error: 'invalid_filename' });
|
|
}
|
|
|
|
const filePath = path.join(WIKI_DIR, filename);
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
|
}
|
|
|
|
fs.unlinkSync(filePath);
|
|
|
|
broadcast('wiki_deleted', { filename });
|
|
res.json({ success: true });
|
|
} catch (err) {
|
|
console.error('Error deleting wiki page:', err);
|
|
res.status(500).json({ error: 'failed_to_delete_wiki_page' });
|
|
}
|
|
});
|
|
|
|
// ============ AGENTS API (Enhanced) ============
|
|
|
|
app.get('/api/agents', (req, res) => {
|
|
try {
|
|
const agents = [];
|
|
|
|
if (fs.existsSync(AGENTS_DIR)) {
|
|
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
|
|
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
|
|
});
|
|
|
|
// Get task counts per agent (workload) and completed tasks (history)
|
|
const getAgentTaskData = (agentName) => {
|
|
return new Promise((resolve) => {
|
|
const result = {
|
|
workload: 0,
|
|
activeTasks: [],
|
|
completedTasks: []
|
|
};
|
|
|
|
// Get workload (tasks in Todo, In Progress, Review)
|
|
db.all(
|
|
`SELECT * FROM tasks
|
|
WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
|
|
ORDER BY priority DESC, created_at ASC`,
|
|
[agentName],
|
|
(err, activeRows) => {
|
|
if (!err && activeRows) {
|
|
result.workload = activeRows.length;
|
|
result.activeTasks = activeRows.map(normalizeTask);
|
|
}
|
|
|
|
// Get last 5 completed tasks
|
|
db.all(
|
|
`SELECT * FROM tasks
|
|
WHERE assignee = ? AND status = 'Done'
|
|
ORDER BY completed_at DESC
|
|
LIMIT 5`,
|
|
[agentName],
|
|
(err2, completedRows) => {
|
|
if (!err2 && completedRows) {
|
|
result.completedTasks = completedRows.map(normalizeTask);
|
|
}
|
|
resolve(result);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
};
|
|
const agentPromises = agentDirs.map(async (agentName) => {
|
|
const agentPath = path.join(AGENTS_DIR, agentName);
|
|
const workspacePath = path.join(agentPath, 'workspace');
|
|
|
|
// Load openclaw config for identity info
|
|
let emoji = '🤖';
|
|
let model = 'unknown';
|
|
let identityName = agentName;
|
|
|
|
if (fs.existsSync(OPENCLAW_CONFIG)) {
|
|
try {
|
|
const openclawConfig = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
|
|
const agentConfig = openclawConfig.agents?.list?.find(a => a.id === agentName);
|
|
if (agentConfig) {
|
|
emoji = agentConfig.identity?.emoji || '🤖';
|
|
model = agentConfig.model?.primary || 'unknown';
|
|
identityName = agentConfig.identity?.name || agentName;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Get last activity from session files
|
|
let lastActivity = null;
|
|
const sessionsPath = path.join(agentPath, 'sessions');
|
|
if (fs.existsSync(sessionsPath)) {
|
|
const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.jsonl'));
|
|
if (sessionFiles.length > 0) {
|
|
const latestSession = sessionFiles
|
|
.map(f => ({ file: f, mtime: fs.statSync(path.join(sessionsPath, f)).mtime }))
|
|
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
if (latestSession) {
|
|
lastActivity = latestSession.mtime.toISOString();
|
|
}
|
|
}
|
|
}
|
|
|
|
const agent = {
|
|
id: agentName,
|
|
emoji,
|
|
model,
|
|
lastActivity,
|
|
name: identityName,
|
|
status: 'active',
|
|
currentTask: null,
|
|
tools: [],
|
|
files: [],
|
|
permissions: [],
|
|
workload: 0,
|
|
activeTasks: [],
|
|
completedTasks: [],
|
|
capabilities: []
|
|
};
|
|
|
|
if (fs.existsSync(workspacePath)) {
|
|
const files = fs.readdirSync(workspacePath);
|
|
agent.files = files.filter(f => f.endsWith('.md'));
|
|
|
|
const memoryPath = path.join(workspacePath, 'MEMORY.md');
|
|
if (fs.existsSync(memoryPath)) {
|
|
const memory = fs.readFileSync(memoryPath, 'utf8');
|
|
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
|
|
if (toolMatches) {
|
|
agent.tools = toolMatches[1].split('\n')
|
|
.filter(line => line.trim().startsWith('-'))
|
|
.map(line => line.replace(/^-\s*/, '').trim());
|
|
}
|
|
|
|
// Extract capabilities/skills
|
|
const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
|
|
if (skillsMatch) {
|
|
agent.capabilities = skillsMatch[1].split('\n')
|
|
.filter(line => line.trim().startsWith('-'))
|
|
.map(line => line.replace(/^-\s*/, '').trim());
|
|
}
|
|
}
|
|
|
|
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
|
|
if (fs.existsSync(heartbeatPath)) {
|
|
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
|
|
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
|
|
if (taskMatch) {
|
|
agent.currentTask = taskMatch[1].trim();
|
|
}
|
|
|
|
// Check last heartbeat time for status
|
|
const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
|
|
if (timeMatch) {
|
|
const lastBeat = new Date(timeMatch[1]);
|
|
const now = new Date();
|
|
const minutesAgo = (now - lastBeat) / 1000 / 60;
|
|
|
|
if (minutesAgo > 30) {
|
|
agent.status = 'idle';
|
|
} else if (minutesAgo > 10) {
|
|
agent.status = 'busy';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get task data from database
|
|
const taskData = await getAgentTaskData(agentName);
|
|
agent.workload = taskData.workload;
|
|
agent.activeTasks = taskData.activeTasks;
|
|
agent.completedTasks = taskData.completedTasks;
|
|
|
|
return agent;
|
|
});
|
|
|
|
Promise.all(agentPromises).then(results => {
|
|
res.json(results);
|
|
});
|
|
} else {
|
|
res.json([]);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error reading agents:', err);
|
|
res.status(500).json({ error: 'failed_to_fetch_agents' });
|
|
}
|
|
});
|
|
|
|
// POST /api/agents/:name/assign - Assign task to agent
|
|
app.post('/api/agents/:name/assign', (req, res) => {
|
|
const agentName = req.params.name;
|
|
const { taskId } = req.body;
|
|
|
|
if (!taskId) {
|
|
return res.status(400).json({ error: 'taskId_is_required' });
|
|
}
|
|
|
|
db.run(
|
|
'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
|
|
[agentName, taskId],
|
|
function(err) {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'failed_to_assign_task' });
|
|
}
|
|
|
|
if (this.changes === 0) {
|
|
return res.status(404).json({ error: 'task_not_found' });
|
|
}
|
|
|
|
db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
|
|
if (fetchErr || !row) {
|
|
return res.status(500).json({ error: 'failed_to_fetch_task' });
|
|
}
|
|
|
|
const task = normalizeTask(row);
|
|
broadcast('task_assigned', { agent: agentName, task });
|
|
res.json({ success: true, task });
|
|
});
|
|
}
|
|
);
|
|
});
|
|
|
|
// ============ USAGE API (Enhanced) ============
|
|
|
|
// GET /api/usage - Basic usage info (existing)
|
|
app.get('/api/usage', (req, res) => {
|
|
try {
|
|
const usage = {
|
|
providers: [],
|
|
lastUpdated: new Date().toISOString()
|
|
};
|
|
|
|
if (fs.existsSync(OPENCLAW_CONFIG)) {
|
|
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
|
|
|
|
if (config.models) {
|
|
const providerMap = {};
|
|
|
|
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
|
|
const provider = modelConfig.provider || 'unknown';
|
|
|
|
if (!providerMap[provider]) {
|
|
providerMap[provider] = {
|
|
name: provider,
|
|
models: [],
|
|
quota: {
|
|
requests: 0,
|
|
tokens: 0,
|
|
limit: 'unlimited'
|
|
}
|
|
};
|
|
}
|
|
|
|
providerMap[provider].models.push({
|
|
name: modelName,
|
|
type: modelConfig.type || 'chat',
|
|
contextWindow: modelConfig.context_window || 'unknown'
|
|
});
|
|
});
|
|
|
|
usage.providers = Object.values(providerMap);
|
|
}
|
|
}
|
|
|
|
res.json(usage);
|
|
} catch (err) {
|
|
console.error('Error reading usage:', err);
|
|
res.status(500).json({ error: 'failed_to_fetch_usage' });
|
|
}
|
|
});
|
|
|
|
// GET /api/usage/stats - Usage statistics with date range
|
|
app.get('/api/usage/stats', (req, res) => {
|
|
const { from, to } = req.query;
|
|
|
|
let query = 'SELECT * FROM usage_tracking';
|
|
const params = [];
|
|
const conditions = [];
|
|
|
|
if (from) {
|
|
conditions.push('timestamp >= ?');
|
|
params.push(from);
|
|
}
|
|
|
|
if (to) {
|
|
conditions.push('timestamp <= ?');
|
|
params.push(to);
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query += ' WHERE ' + conditions.join(' AND ');
|
|
}
|
|
|
|
query += ' ORDER BY timestamp DESC';
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching usage stats:', err);
|
|
return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
|
|
}
|
|
|
|
// Aggregate stats
|
|
const stats = {
|
|
totalRequests: rows.length,
|
|
totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
|
|
totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
|
|
byProvider: {},
|
|
byAgent: {},
|
|
byModel: {},
|
|
records: rows
|
|
};
|
|
|
|
rows.forEach(record => {
|
|
// By provider
|
|
if (!stats.byProvider[record.provider]) {
|
|
stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
|
|
}
|
|
stats.byProvider[record.provider].requests++;
|
|
stats.byProvider[record.provider].tokens += record.tokens_used || 0;
|
|
stats.byProvider[record.provider].cost += record.cost_estimate || 0;
|
|
|
|
// By agent
|
|
if (!stats.byAgent[record.agent]) {
|
|
stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
|
|
}
|
|
stats.byAgent[record.agent].requests++;
|
|
stats.byAgent[record.agent].tokens += record.tokens_used || 0;
|
|
stats.byAgent[record.agent].cost += record.cost_estimate || 0;
|
|
|
|
// By model
|
|
if (!stats.byModel[record.model]) {
|
|
stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
|
|
}
|
|
stats.byModel[record.model].requests++;
|
|
stats.byModel[record.model].tokens += record.tokens_used || 0;
|
|
stats.byModel[record.model].cost += record.cost_estimate || 0;
|
|
});
|
|
|
|
res.json(stats);
|
|
});
|
|
});
|
|
|
|
// GET /api/usage/agents - Usage breakdown by agent
|
|
app.get('/api/usage/agents', (req, res) => {
|
|
const { from, to } = req.query;
|
|
|
|
let query = `
|
|
SELECT agent,
|
|
COUNT(*) as requests,
|
|
SUM(tokens_used) as tokens,
|
|
SUM(cost_estimate) as cost,
|
|
provider,
|
|
model
|
|
FROM usage_tracking
|
|
`;
|
|
const params = [];
|
|
const conditions = [];
|
|
|
|
if (from) {
|
|
conditions.push('timestamp >= ?');
|
|
params.push(from);
|
|
}
|
|
|
|
if (to) {
|
|
conditions.push('timestamp <= ?');
|
|
params.push(to);
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query += ' WHERE ' + conditions.join(' AND ');
|
|
}
|
|
|
|
query += ' GROUP BY agent ORDER BY requests DESC';
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error('Error fetching agent usage:', err);
|
|
return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
|
|
}
|
|
|
|
res.json(rows);
|
|
});
|
|
});
|
|
|
|
// POST /api/usage/track - Track usage (for external callers)
|
|
app.post('/api/usage/track', (req, res) => {
|
|
const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
|
|
|
|
if (!agent || !provider || !model) {
|
|
return res.status(400).json({ error: 'agent, provider, and model are required' });
|
|
}
|
|
|
|
db.run(
|
|
`INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
|
|
function(err) {
|
|
if (err) {
|
|
console.error('Error tracking usage:', err);
|
|
return res.status(500).json({ error: 'failed_to_track_usage' });
|
|
}
|
|
|
|
res.status(201).json({ success: true, id: this.lastID });
|
|
}
|
|
);
|
|
});
|
|
|
|
// GET /api/usage/export - Export usage data
|
|
app.get('/api/usage/export', (req, res) => {
|
|
const { format = 'json', from, to } = req.query;
|
|
|
|
let query = 'SELECT * FROM usage_tracking';
|
|
const params = [];
|
|
const conditions = [];
|
|
|
|
if (from) {
|
|
conditions.push('timestamp >= ?');
|
|
params.push(from);
|
|
}
|
|
|
|
if (to) {
|
|
conditions.push('timestamp <= ?');
|
|
params.push(to);
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query += ' WHERE ' + conditions.join(' AND ');
|
|
}
|
|
|
|
query += ' ORDER BY timestamp DESC';
|
|
|
|
db.all(query, params, (err, rows) => {
|
|
if (err) {
|
|
console.error('Error exporting usage:', err);
|
|
return res.status(500).json({ error: 'failed_to_export_usage' });
|
|
}
|
|
|
|
if (format === 'csv') {
|
|
const csv = [
|
|
'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
|
|
...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
|
|
].join('\n');
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
|
|
res.send(csv);
|
|
} else {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
|
|
res.json(rows);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============ HEARTBEAT ============
|
|
|
|
app.get('/api/heartbeat/:agent', (req, res) => {
|
|
const agent = req.params.agent;
|
|
|
|
db.all(
|
|
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
|
|
[agent, 'Todo', 'In Progress', 'Review'],
|
|
(err, rows) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
|
|
}
|
|
|
|
const tasks = rows.map(normalizeTask);
|
|
res.json({
|
|
agent,
|
|
pending_tasks: tasks.length,
|
|
tasks
|
|
});
|
|
}
|
|
);
|
|
});
|
|
|
|
// ============ WEBSOCKET ============
|
|
|
|
wss.on('connection', (socket) => {
|
|
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
|
|
});
|
|
|
|
// Setup Gitea integration
|
|
setupGiteaRoutes(app, renderPage);
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`openclaw-taskboard listening on ${PORT}`);
|
|
});
|
|
|
|
// ============ REAL USAGE TRACKING ============
|
|
const REAL_SESSIONS_DIR = process.env.SESSIONS_DIR || '/app/agents';
|
|
const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || '/app/swarm/active-tasks.json';
|
|
|
|
// GET /api/usage/real - Aggregate usage from session files
|
|
// GET /api/usage/real - Aggregate usage from session files with date filtering
|
|
app.get('/api/usage/real', async (req, res) => {
|
|
try {
|
|
const { from, to } = req.query;
|
|
const fromDate = from ? new Date(from) : null;
|
|
const toDate = to ? new Date(to) : null;
|
|
|
|
const usageByAgent = {};
|
|
const usageByModel = {};
|
|
let totalInput = 0, totalOutput = 0, totalCost = 0;
|
|
|
|
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
|
|
return res.json({ error: 'sessions_dir_not_found', agents: {}, totals: {} });
|
|
}
|
|
|
|
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
|
|
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
|
|
});
|
|
|
|
for (const agent of agents) {
|
|
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
|
|
if (!fs.existsSync(sessionsDir)) continue;
|
|
|
|
let agentInput = 0, agentOutput = 0, agentCost = 0;
|
|
|
|
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
for (const sessionFile of sessions) {
|
|
const filePath = path.join(sessionsDir, sessionFile);
|
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
|
|
// Date filtering
|
|
if (fromDate || toDate) {
|
|
const msgDate = new Date(msg.timestamp);
|
|
if (fromDate && msgDate < fromDate) continue;
|
|
if (toDate && msgDate > toDate) continue;
|
|
}
|
|
|
|
if (msg.message?.usage) {
|
|
const u = msg.message.usage;
|
|
agentInput += u.input || 0;
|
|
agentOutput += u.output || 0;
|
|
agentCost += u.cost?.total || 0;
|
|
|
|
const model = msg.message.model || 'unknown';
|
|
if (!usageByModel[model]) {
|
|
usageByModel[model] = { input: 0, output: 0, requests: 0 };
|
|
}
|
|
usageByModel[model].input += u.input || 0;
|
|
usageByModel[model].output += u.output || 0;
|
|
usageByModel[model].requests++;
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
usageByAgent[agent] = {
|
|
input: agentInput,
|
|
output: agentOutput,
|
|
total: agentInput + agentOutput,
|
|
cost: agentCost
|
|
};
|
|
|
|
totalInput += agentInput;
|
|
totalOutput += agentOutput;
|
|
totalCost += agentCost;
|
|
}
|
|
|
|
res.json({
|
|
agents: usageByAgent,
|
|
models: usageByModel,
|
|
totals: {
|
|
input: totalInput,
|
|
output: totalOutput,
|
|
total: totalInput + totalOutput,
|
|
cost: totalCost
|
|
},
|
|
filters: { from, to },
|
|
lastUpdated: new Date().toISOString()
|
|
});
|
|
} catch (err) {
|
|
console.error('Error calculating real usage:', err);
|
|
res.status(500).json({ error: 'failed_to_calculate_usage' });
|
|
}
|
|
});
|
|
|
|
// GET /api/usage/export/real - Export real usage data
|
|
app.get('/api/usage/export/real', (req, res) => {
|
|
const { format = 'json', from, to } = req.query;
|
|
|
|
try {
|
|
const fromDate = from ? new Date(from) : null;
|
|
const toDate = to ? new Date(to) : null;
|
|
const usageData = [];
|
|
|
|
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
|
|
return res.status(404).json({ error: 'sessions_dir_not_found' });
|
|
}
|
|
|
|
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
|
|
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
|
|
});
|
|
|
|
for (const agent of agents) {
|
|
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
|
|
if (!fs.existsSync(sessionsDir)) continue;
|
|
|
|
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
for (const sessionFile of sessions) {
|
|
const filePath = path.join(sessionsDir, sessionFile);
|
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
|
|
if (fromDate || toDate) {
|
|
const msgDate = new Date(msg.timestamp);
|
|
if (fromDate && msgDate < fromDate) continue;
|
|
if (toDate && msgDate > toDate) continue;
|
|
}
|
|
|
|
if (msg.message?.usage) {
|
|
usageData.push({
|
|
timestamp: msg.timestamp,
|
|
agent,
|
|
model: msg.message.model || 'unknown',
|
|
provider: msg.message.provider || 'unknown',
|
|
input: msg.message.usage.input || 0,
|
|
output: msg.message.usage.output || 0,
|
|
total: (msg.message.usage.input || 0) + (msg.message.usage.output || 0),
|
|
cost: msg.message.usage.cost?.total || 0
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (format === 'csv') {
|
|
const csv = [
|
|
'timestamp,agent,model,provider,input,output,total,cost',
|
|
...usageData.map(r => `${r.timestamp},${r.agent},${r.model},${r.provider},${r.input},${r.output},${r.total},${r.cost}`)
|
|
].join('\n');
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
|
|
res.send(csv);
|
|
} else {
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
|
|
res.json(usageData);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error exporting usage:', err);
|
|
res.status(500).json({ error: 'failed_to_export_usage' });
|
|
}
|
|
});
|
|
app.get('/api/swarm/tasks', (req, res) => {
|
|
try {
|
|
if (fs.existsSync(SWARM_TASKS_FILE)) {
|
|
const data = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, 'utf8'));
|
|
res.json(data);
|
|
} else {
|
|
res.json({ tasks: [], message: 'swarm_registry_not_found' });
|
|
}
|
|
} catch (err) {
|
|
console.error('Error reading swarm tasks:', err);
|
|
res.status(500).json({ error: 'failed_to_read_swarm_tasks' });
|
|
}
|
|
});
|