feat(phase5): system awareness + operator dashboard
Adds src/system-state.ts which collects jail/ZFS/PF/budget/task status into a compact summary string. Injects that summary into every pi agent session via --append-system-prompt so agents know what's running before acting. Adds scripts/dashboard.ts which generates a self-contained HTML operator dashboard (no LLM — plain TypeScript). Wires dashboard regen into the controlplane heartbeat loop via CONTROLPLANE_DASHBOARD_INTERVAL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- Build: pass | Tests: FAIL — Tests 40 failed | 766 passed (806) --- Build: pass | Tests: FAIL — Tests 40 failed | 766 passed (806)
This commit is contained in:
parent
2160e7859e
commit
749431cad4
5 changed files with 635 additions and 0 deletions
315
scripts/dashboard.ts
Normal file
315
scripts/dashboard.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* scripts/dashboard.ts — Operator dashboard HTML generator.
|
||||
*
|
||||
* Pulls live data from hostd + controlplane, emits a single self-contained
|
||||
* HTML file. No LLM. No runtime JS — pure server-side render.
|
||||
*
|
||||
* Usage:
|
||||
* just dashboard
|
||||
*
|
||||
* Output: html/clawdie/dashboard.html (or $CONTROLPLANE_DASHBOARD_DIR/dashboard.html)
|
||||
*
|
||||
* Skips silently if hostd is unreachable. Never crashes.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
|
||||
const OUT_DIR = process.env.CONTROLPLANE_DASHBOARD_DIR || path.join(ROOT, 'html', 'clawdie');
|
||||
const OUT_FILE = path.join(OUT_DIR, 'dashboard.html');
|
||||
|
||||
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
|
||||
const CP_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
|
||||
const SOCKET_PATH = process.env.HOSTD_SOCKET ?? '/var/run/clawdie-hostd.sock';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HostdResp { ok: boolean; output?: string; error?: string; }
|
||||
|
||||
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResp> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) { settled = true; socket.destroy(); resolve({ ok: false, error: 'timeout' }); }
|
||||
}, 3000);
|
||||
const socket = net.createConnection(SOCKET_PATH);
|
||||
let buf = '';
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ id, op, params }) + '\n'));
|
||||
socket.on('data', (d) => {
|
||||
buf += d.toString();
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const r = JSON.parse(line) as HostdResp & { id?: string };
|
||||
if (r.id === id && !settled) {
|
||||
settled = true; clearTimeout(timer); socket.end(); resolve(r);
|
||||
}
|
||||
} catch { /* partial */ }
|
||||
}
|
||||
});
|
||||
socket.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: e.message }); } });
|
||||
socket.on('close', () => { if (!settled) { settled = true; clearTimeout(timer); resolve({ ok: false, error: 'closed' }); } });
|
||||
});
|
||||
}
|
||||
|
||||
function cpGet<T>(urlPath: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get({ hostname: CP_HOST, port: CP_PORT, path: urlPath, timeout: 2000 }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c: Buffer) => { body += c.toString(); });
|
||||
res.on('end', () => { try { resolve(JSON.parse(body) as T); } catch { reject(new Error('non-json')); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
function parseSize(s: string): number {
|
||||
const m = /^([\d.]+)([KMGT]?)$/i.exec(s.trim());
|
||||
if (!m) return 0;
|
||||
const units: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
||||
return parseFloat(m[1]) * (units[m[2].toUpperCase()] ?? 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function stateTag(state: string): string {
|
||||
const color = state === 'running' ? '#2ecc71' : state === 'stopped' ? '#e74c3c' : '#f39c12';
|
||||
return `<span style="background:${color};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(state)}</span>`;
|
||||
}
|
||||
|
||||
function taskStatusTag(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
done: '#2ecc71', completed: '#2ecc71',
|
||||
in_progress: '#3498db', running: '#3498db',
|
||||
failed: '#e74c3c', error: '#e74c3c',
|
||||
pending: '#f39c12',
|
||||
};
|
||||
const c = colors[status] ?? '#95a5a6';
|
||||
return `<span style="background:${c};color:#fff;padding:2px 8px;border-radius:3px;font-size:0.85em">${esc(status)}</span>`;
|
||||
}
|
||||
|
||||
function usageBar(pct: number): string {
|
||||
const color = pct > 85 ? '#e74c3c' : pct > 65 ? '#f39c12' : '#2ecc71';
|
||||
return `<div style="background:#2a2a2a;border-radius:4px;height:8px;width:160px;display:inline-block;vertical-align:middle">
|
||||
<div style="background:${color};height:100%;width:${Math.min(pct, 100)}%;border-radius:4px"></div>
|
||||
</div> <span style="font-size:0.85em;color:#aaa">${pct}%</span>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Task { id: string; title: string; status: string; assigned_to?: string; created_at?: string; }
|
||||
interface Approval { id: string; description: string; requested_at: string; }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const generated = new Date().toLocaleString('en-GB', {
|
||||
dateStyle: 'medium', timeStyle: 'short',
|
||||
});
|
||||
|
||||
// Fetch all data concurrently, never throw
|
||||
const [jailResp, zfsResp, pfResp, tasksResp, stateResp, approvalsResp] = await Promise.all([
|
||||
callHostd('bastille-list'),
|
||||
callHostd('zfs-list'),
|
||||
callHostd('pf-status'),
|
||||
cpGet<{ tasks?: Task[] }>('/api/controlplane/tasks').catch(() => ({ tasks: undefined })),
|
||||
cpGet<{ budget?: { spentToday?: number; dailyLimit?: number }; agents?: Array<{ id: string; status: string }> }>('/api/controlplane/state').catch(() => ({})),
|
||||
cpGet<{ approvals?: Approval[] }>('/api/controlplane/approvals').catch(() => ({ approvals: undefined })),
|
||||
]);
|
||||
|
||||
const hostdReachable = jailResp.ok || zfsResp.ok;
|
||||
|
||||
// --- Parse jails ---
|
||||
type JailRow = { name: string; ip: string; jid: string; state: string };
|
||||
const jails: JailRow[] = [];
|
||||
if (jailResp.ok) {
|
||||
for (const line of (jailResp.output ?? '').split('\n')) {
|
||||
if (!line.trim() || /^\s*(JID|---)/i.test(line)) continue;
|
||||
const p = line.trim().split(/\s+/);
|
||||
const jid = p[0] ?? '-';
|
||||
jails.push({ jid, ip: p[1] ?? '-', name: p[2] ?? '-', state: (jid && jid !== '0' && jid !== '-') ? 'running' : 'stopped' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Parse ZFS ---
|
||||
type ZfsRow = { dataset: string; used: string; avail: string; pct: number };
|
||||
const zfsRows: ZfsRow[] = [];
|
||||
if (zfsResp.ok) {
|
||||
for (const line of (zfsResp.output ?? '').split('\n')) {
|
||||
if (!line.trim() || /^NAME/i.test(line)) continue;
|
||||
const p = line.trim().split(/\s+/);
|
||||
const used = parseSize(p[1] ?? '0');
|
||||
const avail = parseSize(p[2] ?? '0');
|
||||
const total = used + avail;
|
||||
zfsRows.push({ dataset: p[0] ?? '-', used: p[1] ?? '-', avail: p[2] ?? '-', pct: total > 0 ? Math.round((used / total) * 100) : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Budget ---
|
||||
const budget = (stateResp as { budget?: { spentToday?: number; dailyLimit?: number } }).budget;
|
||||
const budgetPct = (budget?.spentToday && budget?.dailyLimit)
|
||||
? Math.round((budget.spentToday / budget.dailyLimit) * 100) : null;
|
||||
|
||||
// --- Tasks ---
|
||||
const tasks: Task[] = (tasksResp as { tasks?: Task[] }).tasks ?? [];
|
||||
const approvals: Approval[] = (approvalsResp as { approvals?: Approval[] }).approvals ?? [];
|
||||
|
||||
// --- HTML ---
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Clawdie Dashboard</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Courier New', monospace; background: #0f0f0f; color: #ddd; padding: 24px; }
|
||||
h1 { color: #fff; margin-bottom: 4px; font-size: 1.4em; }
|
||||
.meta { color: #666; font-size: 0.8em; margin-bottom: 32px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 24px; }
|
||||
.card { background: #181818; border: 1px solid #2a2a2a; border-radius: 6px; padding: 20px; }
|
||||
.card h2 { font-size: 0.95em; color: #aaa; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; border-bottom: 1px solid #2a2a2a; padding-bottom: 8px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
||||
td, th { padding: 6px 8px; text-align: left; border-bottom: 1px solid #222; }
|
||||
th { color: #666; font-weight: normal; font-size: 0.8em; text-transform: uppercase; }
|
||||
.unreachable { color: #666; font-style: italic; font-size: 0.9em; }
|
||||
.approve-btn { background: #2ecc71; color: #000; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; margin-right: 4px; }
|
||||
.reject-btn { background: #e74c3c; color: #fff; border: none; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 0.8em; }
|
||||
.pf-ok { color: #2ecc71; } .pf-warn { color: #e74c3c; }
|
||||
.budget-row { margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Clawdie Dashboard</h1>
|
||||
<div class="meta">Generated ${esc(generated)}${!hostdReachable ? ' · <span style="color:#e74c3c">hostd unreachable</span>' : ''}</div>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- Jails -->
|
||||
<div class="card">
|
||||
<h2>Jails</h2>
|
||||
${jails.length === 0
|
||||
? '<p class="unreachable">No data — hostd unreachable or no jails configured</p>'
|
||||
: `<table>
|
||||
<thead><tr><th>Name</th><th>IP</th><th>JID</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
${jails.map((j) => `<tr><td>${esc(j.name)}</td><td>${esc(j.ip)}</td><td>${esc(j.jid)}</td><td>${stateTag(j.state)}</td></tr>`).join('\n ')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
|
||||
<!-- ZFS -->
|
||||
<div class="card">
|
||||
<h2>ZFS Storage</h2>
|
||||
${zfsRows.length === 0
|
||||
? '<p class="unreachable">No data — hostd unreachable</p>'
|
||||
: `<table>
|
||||
<thead><tr><th>Dataset</th><th>Used</th><th>Avail</th><th>Usage</th></tr></thead>
|
||||
<tbody>
|
||||
${zfsRows.map((z) => `<tr><td>${esc(z.dataset)}</td><td>${esc(z.used)}</td><td>${esc(z.avail)}</td><td>${usageBar(z.pct)}</td></tr>`).join('\n ')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="card">
|
||||
<h2>System</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>PF firewall</td><td>${
|
||||
pfResp.ok
|
||||
? `<span class="pf-ok">enabled</span>`
|
||||
: `<span class="pf-warn">unknown</span>`
|
||||
}</td></tr>
|
||||
${budget ? `<tr><td>Budget today</td><td class="budget-row">${usageBar(budgetPct ?? 0)} ${(budget.spentToday ?? 0).toLocaleString()} / ${(budget.dailyLimit ?? 0).toLocaleString()} tokens</td></tr>` : ''}
|
||||
<tr><td>Active tasks</td><td>${tasks.filter((t) => t.status === 'in_progress' || t.status === 'running').length}</td></tr>
|
||||
<tr><td>Pending approvals</td><td>${approvals.length}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Tasks -->
|
||||
<div class="card">
|
||||
<h2>Recent Tasks</h2>
|
||||
${tasks.length === 0
|
||||
? '<p class="unreachable">Controlplane not reachable or no tasks</p>'
|
||||
: `<table>
|
||||
<thead><tr><th>ID</th><th>Title</th><th>Agent</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
${tasks.slice(0, 15).map((t) => `<tr>
|
||||
<td style="color:#666;font-size:0.8em">${esc(t.id.slice(0, 8))}</td>
|
||||
<td>${esc(t.title.slice(0, 40))}</td>
|
||||
<td style="color:#888">${esc(t.assigned_to ?? '—')}</td>
|
||||
<td>${taskStatusTag(t.status)}</td>
|
||||
</tr>`).join('\n ')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
|
||||
${approvals.length > 0 ? `
|
||||
<!-- Approvals -->
|
||||
<div class="card">
|
||||
<h2>Pending Approvals</h2>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Description</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
${approvals.map((a) => `<tr>
|
||||
<td style="color:#666;font-size:0.8em">${esc(a.id.slice(0, 8))}</td>
|
||||
<td>${esc(a.description.slice(0, 50))}</td>
|
||||
<td>
|
||||
<button class="approve-btn" onclick="decide('${esc(a.id)}','approve')">Approve</button>
|
||||
<button class="reject-btn" onclick="decide('${esc(a.id)}','reject')">Reject</button>
|
||||
</td>
|
||||
</tr>`).join('\n ')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function decide(id, action) {
|
||||
try {
|
||||
const r = await fetch('/api/controlplane/approvals/' + id, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision: action }),
|
||||
});
|
||||
if (r.ok) location.reload();
|
||||
else alert('Failed: ' + await r.text());
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
if (!fs.existsSync(OUT_DIR)) {
|
||||
console.error(`Output directory not found: ${OUT_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUT_FILE, html, 'utf-8');
|
||||
console.log(`Dashboard written to ${OUT_FILE}`);
|
||||
if (!hostdReachable) console.log(' (hostd unreachable — jail/ZFS sections are empty)');
|
||||
}
|
||||
|
||||
main().catch((err: Error) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from './config.js';
|
||||
import { AGENT_IDENTITY } from './agent-identity.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { systemStateSummary } from './system-state.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { markJailRunFinished, markJailRunStarted } from './health.js';
|
||||
import { incCounter, incLabeledCounter } from './metrics.js';
|
||||
|
|
@ -176,10 +177,12 @@ export async function runJailAgent(
|
|||
// This keeps ephemeral context out of the session file — it is never stored
|
||||
// in the JSONL history, so it cannot compound across turns.
|
||||
const runtimeInfo = `Rule: Your current LLM runtime is provider=${PI_TUI_PROVIDER || 'unknown'}, model=${PI_TUI_MODEL || 'unknown'}. State this accurately if asked — do not guess or substitute another model name.`;
|
||||
const stateInfo = await systemStateSummary();
|
||||
const systemPrompt = [
|
||||
AGENT_IDENTITY.selfIntro,
|
||||
PI_TUI_APPEND_SYSTEM_PROMPT,
|
||||
runtimeInfo,
|
||||
stateInfo,
|
||||
input.systemContext,
|
||||
].filter(Boolean).join('\n\n');
|
||||
if (systemPrompt) {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ const envConfig = readEnvFile([
|
|||
'CONTROLPLANE_BIND_HOST',
|
||||
'BETTER_AUTH_SECRET',
|
||||
'CONTROLPLANE_DASHBOARD_DIR',
|
||||
'CONTROLPLANE_DASHBOARD_INTERVAL',
|
||||
'CONTROLPLANE_RUNNER',
|
||||
'CONTROLPLANE_AIDER_BIN',
|
||||
'CONTROLPLANE_AIDER_FLAGS',
|
||||
|
|
@ -502,6 +503,13 @@ export const CONTROLPLANE_DASHBOARD_DIR =
|
|||
process.env.CONTROLPLANE_DASHBOARD_DIR ||
|
||||
envConfig.CONTROLPLANE_DASHBOARD_DIR ||
|
||||
'';
|
||||
export const CONTROLPLANE_DASHBOARD_INTERVAL_MS =
|
||||
parseInt(
|
||||
process.env.CONTROLPLANE_DASHBOARD_INTERVAL ||
|
||||
envConfig.CONTROLPLANE_DASHBOARD_INTERVAL ||
|
||||
'300',
|
||||
10,
|
||||
) * 1000;
|
||||
export type ControlplaneRunner = 'pi' | 'aider';
|
||||
export const CONTROLPLANE_RUNNER: ControlplaneRunner = (process.env
|
||||
.CONTROLPLANE_RUNNER ||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import {
|
|||
CONTROLPLANE_AIDER_LOG_DIR,
|
||||
CONTROLPLANE_AIDER_TMUX_SESSION,
|
||||
CONTROLPLANE_API_PORT,
|
||||
CONTROLPLANE_DASHBOARD_DIR,
|
||||
CONTROLPLANE_DASHBOARD_INTERVAL_MS,
|
||||
CONTROLPLANE_RUNNER,
|
||||
} from './config.js';
|
||||
import { resolveIdentityFile } from './controlplane-runner.js';
|
||||
|
|
@ -318,12 +320,32 @@ export async function runAgentHeartbeat(
|
|||
|
||||
let heartbeatRunning = false;
|
||||
|
||||
async function regenerateDashboard(): Promise<void> {
|
||||
if (!CONTROLPLANE_DASHBOARD_DIR) return;
|
||||
const { existsSync } = await import('fs');
|
||||
if (!existsSync(CONTROLPLANE_DASHBOARD_DIR)) return;
|
||||
try {
|
||||
const { spawnSync } = await import('child_process');
|
||||
const result = spawnSync('npx', ['tsx', 'scripts/dashboard.ts'], { encoding: 'utf-8' });
|
||||
if (result.status !== 0) {
|
||||
logger.warn({ stderr: result.stderr }, 'Dashboard regeneration failed');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Dashboard regeneration error');
|
||||
}
|
||||
}
|
||||
|
||||
export function startControlplaneHeartbeatLoop(
|
||||
config: ControlplaneHeartbeatConfig,
|
||||
): void {
|
||||
if (heartbeatRunning) return;
|
||||
heartbeatRunning = true;
|
||||
|
||||
// Dashboard regen on interval (fire-and-forget, non-fatal)
|
||||
if (CONTROLPLANE_DASHBOARD_DIR && CONTROLPLANE_DASHBOARD_INTERVAL_MS > 0) {
|
||||
setInterval(() => { void regenerateDashboard(); }, CONTROLPLANE_DASHBOARD_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const loop = async () => {
|
||||
try {
|
||||
const agents = await getAgents(config.pool);
|
||||
|
|
|
|||
287
src/system-state.ts
Normal file
287
src/system-state.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* src/system-state.ts — Lightweight system snapshot for agent prompt injection.
|
||||
*
|
||||
* Collects jail status, ZFS usage, PF state, task queue depth, and token budget
|
||||
* into a single compact summary string. All calls are best-effort: if hostd or
|
||||
* the controlplane is unreachable, the relevant section is omitted gracefully.
|
||||
*
|
||||
* Usage:
|
||||
* const summary = await systemStateSummary();
|
||||
* // → "System state (14:22): Jails: db=running cms=running | ZFS: 43% used | ..."
|
||||
*/
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JailInfo {
|
||||
name: string;
|
||||
state: 'running' | 'stopped' | 'unknown';
|
||||
ip: string;
|
||||
jid: string;
|
||||
}
|
||||
|
||||
export interface ZfsSummary {
|
||||
pool: string;
|
||||
usedPct: number;
|
||||
lastSnapshot?: string;
|
||||
}
|
||||
|
||||
export interface SystemSnapshot {
|
||||
timestamp: string;
|
||||
jails: JailInfo[];
|
||||
zfs: ZfsSummary[];
|
||||
pfEnabled: boolean | null;
|
||||
activeTasks: number | null;
|
||||
budgetUsed: number | null;
|
||||
budgetLimit: number | null;
|
||||
hostdReachable: boolean;
|
||||
controlplaneReachable: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hostd helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SOCKET_PATH = process.env.HOSTD_SOCKET ?? '/var/run/clawdie-hostd.sock';
|
||||
const HOSTD_TIMEOUT_MS = 3000;
|
||||
|
||||
interface HostdResponse {
|
||||
ok: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function callHostd(op: string, params: Record<string, string> = {}): Promise<HostdResponse> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const req = JSON.stringify({ id, op, params });
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve({ ok: false, error: 'timeout' });
|
||||
}
|
||||
}, HOSTD_TIMEOUT_MS);
|
||||
|
||||
const socket = net.createConnection(SOCKET_PATH);
|
||||
let buf = '';
|
||||
|
||||
socket.on('connect', () => socket.write(req + '\n'));
|
||||
socket.on('data', (d) => {
|
||||
buf += d.toString();
|
||||
for (const line of buf.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const resp = JSON.parse(line) as HostdResponse & { id?: string };
|
||||
if (resp.id === id && !settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.end();
|
||||
resolve(resp);
|
||||
}
|
||||
} catch { /* partial line */ }
|
||||
}
|
||||
buf = buf.includes('\n') ? buf.slice(buf.lastIndexOf('\n') + 1) : buf;
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
socket.on('close', () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, error: 'connection closed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseBastilleList(output: string): JailInfo[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter((l) => l.trim() && !/^\s*(JID|---)/i.test(l))
|
||||
.map((line) => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const jid = parts[0] ?? '-';
|
||||
const ip = parts[1] ?? '-';
|
||||
const name = parts[2] ?? '-';
|
||||
return {
|
||||
name,
|
||||
ip,
|
||||
jid,
|
||||
state: (jid && jid !== '0' && jid !== '-' ? 'running' : 'stopped') as JailInfo['state'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controlplane helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CP_PORT = parseInt(process.env.CONTROLPLANE_PORT ?? '3100', 10);
|
||||
const CP_HOST = process.env.CONTROLPLANE_BIND_HOST ?? '127.0.0.1';
|
||||
const CP_TIMEOUT_MS = 2000;
|
||||
|
||||
function cpGet<T>(path: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: CP_HOST, port: CP_PORT, path, timeout: CP_TIMEOUT_MS },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (c: Buffer) => { body += c.toString(); });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(body) as T); }
|
||||
catch { reject(new Error('non-json')); }
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot collector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function collectSnapshot(): Promise<SystemSnapshot> {
|
||||
const timestamp = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
// --- hostd calls (all in parallel) ---
|
||||
const [jailResp, zfsResp, pfResp] = await Promise.all([
|
||||
callHostd('bastille-list'),
|
||||
callHostd('zfs-list'),
|
||||
callHostd('pf-status'),
|
||||
]);
|
||||
|
||||
const hostdReachable = jailResp.ok || zfsResp.ok || pfResp.ok;
|
||||
|
||||
// parse jails
|
||||
const jails: JailInfo[] = jailResp.ok
|
||||
? parseBastilleList(jailResp.output ?? '')
|
||||
: [];
|
||||
|
||||
// parse ZFS — pick the pool root lines
|
||||
const zfs: ZfsSummary[] = [];
|
||||
if (zfsResp.ok) {
|
||||
for (const line of (zfsResp.output ?? '').split('\n').filter(Boolean)) {
|
||||
if (/^NAME/i.test(line)) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const name = parts[0] ?? '';
|
||||
const usedRaw = parts[1] ?? '0';
|
||||
const availRaw = parts[2] ?? '0';
|
||||
// Only top-level pools (no '/' in name after first segment)
|
||||
if (name.split('/').length > 2) continue;
|
||||
const used = parseSize(usedRaw);
|
||||
const avail = parseSize(availRaw);
|
||||
const total = used + avail;
|
||||
const usedPct = total > 0 ? Math.round((used / total) * 100) : 0;
|
||||
zfs.push({ pool: name, usedPct });
|
||||
}
|
||||
}
|
||||
|
||||
// PF status
|
||||
const pfEnabled: boolean | null = pfResp.ok
|
||||
? /(running|enabled)/i.test(pfResp.output ?? '')
|
||||
: null;
|
||||
|
||||
// --- controlplane (optional) ---
|
||||
let activeTasks: number | null = null;
|
||||
let budgetUsed: number | null = null;
|
||||
let budgetLimit: number | null = null;
|
||||
let controlplaneReachable = false;
|
||||
|
||||
try {
|
||||
const [tasksResp, stateResp] = await Promise.all([
|
||||
cpGet<{ tasks?: Array<{ status: string }> }>('/api/controlplane/tasks'),
|
||||
cpGet<{ budget?: { spentToday?: number; dailyLimit?: number } }>('/api/controlplane/state'),
|
||||
]);
|
||||
controlplaneReachable = true;
|
||||
const tasks = tasksResp.tasks ?? [];
|
||||
activeTasks = tasks.filter((t) => t.status === 'in_progress' || t.status === 'running').length;
|
||||
if (stateResp.budget) {
|
||||
budgetUsed = stateResp.budget.spentToday ?? null;
|
||||
budgetLimit = stateResp.budget.dailyLimit ?? null;
|
||||
}
|
||||
} catch {
|
||||
// controlplane not running — fine
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
jails,
|
||||
zfs,
|
||||
pfEnabled,
|
||||
activeTasks,
|
||||
budgetUsed,
|
||||
budgetLimit,
|
||||
hostdReachable,
|
||||
controlplaneReachable,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary string for --append-system-prompt injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function systemStateSummary(): Promise<string> {
|
||||
let snap: SystemSnapshot;
|
||||
try {
|
||||
snap = await collectSnapshot();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts: string[] = [`System state (${snap.timestamp}):`];
|
||||
|
||||
if (snap.jails.length > 0) {
|
||||
const jailStr = snap.jails
|
||||
.map((j) => `${j.name}=${j.state}`)
|
||||
.join(' ');
|
||||
parts.push(`Jails: ${jailStr}`);
|
||||
} else if (!snap.hostdReachable) {
|
||||
parts.push('Jails: hostd unreachable');
|
||||
}
|
||||
|
||||
if (snap.zfs.length > 0) {
|
||||
const zfsStr = snap.zfs.map((z) => `${z.pool} ${z.usedPct}% used`).join(', ');
|
||||
parts.push(`ZFS: ${zfsStr}`);
|
||||
}
|
||||
|
||||
if (snap.pfEnabled !== null) {
|
||||
parts.push(`PF: ${snap.pfEnabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
if (snap.budgetUsed !== null && snap.budgetLimit !== null) {
|
||||
parts.push(`Budget: ${snap.budgetUsed.toLocaleString()}/${snap.budgetLimit.toLocaleString()} tokens today`);
|
||||
}
|
||||
|
||||
if (snap.activeTasks !== null) {
|
||||
parts.push(`Active tasks: ${snap.activeTasks}`);
|
||||
}
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12 };
|
||||
|
||||
function parseSize(s: string): number {
|
||||
const match = /^([\d.]+)([KMGT]?)$/i.exec(s.trim());
|
||||
if (!match) return 0;
|
||||
const val = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
return val * (SIZE_UNITS[unit] ?? 1);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue