Phase 7: Mission Ops — multi-step automated workflow engine

- DB: missions, mission_steps, mission_runs tables
- reactor.py v7.0.0: handle_run_mission, _execute_mission, mission_trigger_loop (schedule/guardian_event/email_keyword triggers), {{template}} substitution across steps, full CRUD REST endpoints
- arc.php: missions/mission_get/mission_runs/mission_create/mission_update/mission_delete/mission_run/mission_toggle actions
- admin/index.php: Mission Ops tab with visual workflow builder (trigger config, step cards with ↑↓, JSON payload editor, continue-on-failure flag), run history with step-level detail, enable/disable toggle
- index.html: MISSIONS tab with collapsible mission cards, RUN NOW button per mission, live run result feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 11:49:07 +00:00
parent 8229f52b8b
commit b6c417948e
3 changed files with 621 additions and 0 deletions
+56
View File
@@ -236,6 +236,62 @@ switch ($action) {
echo json_encode(arc_request('DELETE', "/comms/sent/{$id}"));
break;
// ── MISSION OPS ───────────────────────────────────────────────────────────
// GET /api/arc?action=missions
case 'missions':
echo json_encode(arc_request('GET', '/missions'));
break;
// GET /api/arc?action=mission_get&id=123
case 'mission_get':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/missions/{$id}"));
break;
// GET /api/arc?action=mission_runs&id=123
case 'mission_runs':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
$limit = (int)($_GET['limit'] ?? 20);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('GET', "/missions/{$id}/runs?limit={$limit}"));
break;
// POST /api/arc?action=mission_create — body: { name, description, trigger_type, trigger_config, steps }
case 'mission_create':
echo json_encode(arc_request('POST', '/missions', $data));
break;
// POST /api/arc?action=mission_update — body: { id, name, ... steps }
case 'mission_update':
$id = (int)($data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('PUT', "/missions/{$id}", $data));
break;
// DELETE /api/arc?action=mission_delete&id=123
case 'mission_delete':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('DELETE', "/missions/{$id}"));
break;
// POST /api/arc?action=mission_run&id=123
case 'mission_run':
$id = (int)($_GET['id'] ?? $data['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('POST', "/missions/{$id}/run", ['trigger_source' => 'manual']));
break;
// POST /api/arc?action=mission_toggle&id=123 body: { enabled: 1|0 }
case 'mission_toggle':
$id = (int)($data['id'] ?? 0);
$enabled = (int)($data['enabled'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error' => 'Missing id']); break; }
echo json_encode(arc_request('PUT', "/missions/{$id}", ['enabled' => $enabled]));
break;
default:
http_response_code(404);
echo json_encode(['error' => "Unknown arc action: {$action}"]);
+468
View File
@@ -593,6 +593,71 @@ if ($action) {
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
// ── MISSION OPS ──────────────────────────────────────────────────────
case 'mission_list':
$ch = curl_init('http://127.0.0.1:7474/missions');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'mission_get':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'not found']);
case 'mission_runs':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$limit = (int)($_GET['limit'] ?? 20);
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/runs?limit={$limit}");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'mission_save': // create or update
$id = (int)($_GET['id'] ?? 0);
$url = $id ? "http://127.0.0.1:7474/missions/{$id}" : 'http://127.0.0.1:7474/missions';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10,
CURLOPT_CUSTOMREQUEST => $id ? 'PUT' : 'POST',
CURLOPT_POSTFIELDS => file_get_contents('php://input'),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']);
case 'mission_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'mission_run':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>120, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['trigger_source'=>'admin']),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable or timeout']);
case 'mission_toggle':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$enabled = (int)($_GET['enabled'] ?? 0);
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT',
CURLOPT_POSTFIELDS => json_encode(['enabled'=>$enabled]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
// ── VISION PROTOCOL ──────────────────────────────────────────────────
case 'vision_list':
$limit = min((int)($_GET['limit'] ?? 30), 100);
@@ -928,6 +993,7 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="nav-item" data-tab="arc" onclick="nav(this)">⚡ ARC REACTOR</div>
<div class="nav-item" data-tab="vision" onclick="nav(this)">◈ VISION PROTOCOL</div>
<div class="nav-item" data-tab="guardian" onclick="nav(this)" id="nav-guardian">◈ GUARDIAN MODE</div>
<div class="nav-item" data-tab="missions" onclick="nav(this)">◈ MISSION OPS</div>
<div class="nav-section">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
@@ -1325,6 +1391,73 @@ select.filter-sel:focus{border-color:var(--cyan)}
<div class="tbl-wrap" id="outbox-tbl"><div class="loading">LOADING OUTBOX...</div></div>
</div>
<!-- MISSION OPS -->
<div class="tab" id="tab-missions">
<div class="page-title">◈ MISSION OPS — AUTOMATED WORKFLOWS</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="missionNew()">+ NEW MISSION</button>
<button class="btn btn-sm" onclick="loadMissions()">↻ REFRESH</button>
<div id="missions-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<!-- Mission list -->
<div id="missions-list"><div class="loading">LOADING MISSIONS...</div></div>
<!-- Builder panel (hidden until a mission is selected/created) -->
<div id="mission-builder" style="display:none;margin-top:20px;border:1px solid var(--border);border-radius:6px;padding:16px;background:rgba(0,212,255,0.02)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div id="builder-title" style="font-family:var(--mono);font-size:0.75rem;letter-spacing:2px;color:var(--cyan)">◈ MISSION BUILDER</div>
<button class="btn btn-xs" onclick="document.getElementById('mission-builder').style.display='none'">✕ CLOSE</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
<div>
<div class="lbl">MISSION NAME</div>
<input id="mb-name" class="inp" placeholder="e.g. Daily Morning Brief">
</div>
<div>
<div class="lbl">TRIGGER</div>
<select id="mb-trigger" class="inp" onchange="missionTriggerChange()">
<option value="manual">Manual (run by hand)</option>
<option value="schedule">Schedule (every N minutes)</option>
<option value="guardian_event">Guardian Event (on alert)</option>
<option value="email_keyword">Email Keyword (on triage match)</option>
</select>
</div>
</div>
<div id="mb-trigger-config" style="margin-bottom:14px"></div>
<div>
<div class="lbl">DESCRIPTION (optional)</div>
<input id="mb-desc" class="inp" placeholder="What does this mission do?">
</div>
<div style="margin-top:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="lbl" style="margin:0">STEPS</div>
<button class="btn btn-xs btn-green" onclick="missionAddStep()">+ ADD STEP</button>
</div>
<div id="mb-steps"></div>
</div>
<input type="hidden" id="mb-mission-id" value="">
<div style="display:flex;gap:8px;margin-top:16px">
<button class="btn btn-sm btn-green" onclick="missionSave()">◈ SAVE MISSION</button>
<button id="mb-run-btn" class="btn btn-sm" style="display:none" onclick="missionRunFromBuilder()">▶ RUN NOW</button>
<button id="mb-del-btn" class="btn btn-sm btn-red" style="display:none" onclick="missionDeleteFromBuilder()">✗ DELETE</button>
</div>
<div id="mb-status" style="font-family:var(--mono);font-size:0.6rem;color:var(--cyan);margin-top:8px;min-height:14px"></div>
</div>
<!-- Run history panel -->
<div id="mission-run-history" style="display:none;margin-top:16px;border:1px solid var(--border);border-radius:6px;padding:14px">
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:10px">◈ RUN HISTORY</div>
<div id="mission-runs-tbl"></div>
</div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /app -->
@@ -1438,6 +1571,7 @@ function loadTab(tab) {
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
triage: loadTriage,
outbox: loadOutbox,
missions: loadMissions,
vision: loadVision,
guardian: loadGuardian,
tasks: loadTasks,
@@ -2776,6 +2910,340 @@ function outboxCompose() {
}, 'DISPATCH');
}
// ── MISSION OPS ──────────────────────────────────────────────────────────────
const JOB_TYPES = ['ping','echo','shell','llm','research','tool_loop','gmail_triage',
'remote_exec','screenshot','vision','sysinfo','sitrep','send_email','compose_email',
'schedule_event','meeting_prep','run_mission'];
let _missionBuilderSteps = [];
let _missionBuilderStepIdx = 0;
async function loadMissions() {
const el = document.getElementById('missions-list');
if (!el) return;
const missions = await api('mission_list');
const list = Array.isArray(missions) ? missions : [];
document.getElementById('missions-count').textContent = list.length + ' MISSIONS';
if (!list.length) {
el.innerHTML = '<div class="loading">No missions yet. Click + NEW MISSION to create one.</div>';
return;
}
const triggerIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
const statusColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)'};
const rows = list.map(m => {
const icon = triggerIcons[m.trigger_type] || '◈';
const enabled = m.enabled ? '<span style="color:var(--green)">ENABLED</span>' : '<span style="color:var(--dim)">DISABLED</span>';
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleString() : '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.7rem">${icon} ${esc(m.name)}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.trigger_type.replace('_',' ').toUpperCase()}</td>
<td>${enabled}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.run_count||0} runs · last ${lastRun}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs btn-green" onclick="missionRunNow(${m.id})">▶ RUN</button>
<button class="btn btn-xs" onclick="missionEdit(${m.id})">EDIT</button>
<button class="btn btn-xs" onclick="missionViewRuns(${m.id})">HISTORY</button>
<button class="btn btn-xs" onclick="missionToggle(${m.id},${m.enabled?0:1})">${m.enabled?'DISABLE':'ENABLE'}</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>NAME</th><th>TRIGGER</th><th>STATUS</th><th>LAST RUN</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function missionNew() {
_missionBuilderSteps = [];
_missionBuilderStepIdx = 0;
document.getElementById('mb-mission-id').value = '';
document.getElementById('mb-name').value = '';
document.getElementById('mb-desc').value = '';
document.getElementById('mb-trigger').value = 'manual';
document.getElementById('builder-title').textContent = '◈ NEW MISSION';
document.getElementById('mb-run-btn').style.display = 'none';
document.getElementById('mb-del-btn').style.display = 'none';
document.getElementById('mb-status').textContent = '';
missionTriggerChange();
_renderBuilderSteps();
document.getElementById('mission-builder').style.display = 'block';
document.getElementById('mission-run-history').style.display = 'none';
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
}
async function missionEdit(id) {
const m = await api('mission_get', {id});
if (m.error) { toast('Load failed: ' + m.error, 'err'); return; }
document.getElementById('mb-mission-id').value = m.id;
document.getElementById('mb-name').value = m.name || '';
document.getElementById('mb-desc').value = m.description || '';
document.getElementById('mb-trigger').value = m.trigger_type || 'manual';
document.getElementById('builder-title').textContent = '◈ EDIT MISSION — ' + esc(m.name);
document.getElementById('mb-run-btn').style.display = '';
document.getElementById('mb-del-btn').style.display = '';
document.getElementById('mb-status').textContent = '';
missionTriggerChange(m.trigger_config || {});
_missionBuilderSteps = (m.steps || []).map(s => ({
id: ++_missionBuilderStepIdx,
label: s.label || '',
job_type: s.job_type || 'ping',
payload: typeof s.job_payload === 'string' ? s.job_payload : JSON.stringify(s.job_payload||{}, null, 2),
continue_on_failure: s.continue_on_failure ? 1 : 0,
}));
_renderBuilderSteps();
document.getElementById('mission-builder').style.display = 'block';
document.getElementById('mission-run-history').style.display = 'none';
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
}
function missionTriggerChange(cfg) {
const t = document.getElementById('mb-trigger')?.value;
const el = document.getElementById('mb-trigger-config');
if (!el) return;
cfg = cfg || {};
if (t === 'manual') {
el.innerHTML = '';
} else if (t === 'schedule') {
el.innerHTML = `<div class="lbl">INTERVAL (minutes)</div>
<input id="mb-tc-interval" class="inp" type="number" min="1" value="${cfg.interval_minutes||60}" style="width:160px">`;
} else if (t === 'guardian_event') {
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div><div class="lbl">SEVERITY (blank=any)</div>
<select id="mb-tc-severity" class="inp">
<option value="">Any</option>
<option value="critical"${cfg.severity==='critical'?' selected':''}>Critical</option>
<option value="warning"${cfg.severity==='warning'?' selected':''}>Warning</option>
<option value="info"${cfg.severity==='info'?' selected':''}>Info</option>
</select></div>
<div><div class="lbl">EVENT TYPE (blank=any)</div>
<input id="mb-tc-etype" class="inp" value="${cfg.event_type||''}" placeholder="e.g. cpu_high"></div>
</div>`;
} else if (t === 'email_keyword') {
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div><div class="lbl">KEYWORDS (comma-separated)</div>
<input id="mb-tc-keywords" class="inp" value="${(cfg.keywords||[]).join(', ')}" placeholder="urgent, invoice, CEO"></div>
<div><div class="lbl">CATEGORY (blank=any)</div>
<select id="mb-tc-category" class="inp">
<option value="">Any</option>
<option value="urgent"${cfg.category==='urgent'?' selected':''}>Urgent</option>
<option value="action"${cfg.category==='action'?' selected':''}>Action</option>
<option value="reply"${cfg.category==='reply'?' selected':''}>Reply</option>
<option value="meeting"${cfg.category==='meeting'?' selected':''}>Meeting</option>
</select></div>
</div>`;
}
}
function _readTriggerConfig() {
const t = document.getElementById('mb-trigger')?.value;
if (t === 'schedule') {
return {interval_minutes: parseInt(document.getElementById('mb-tc-interval')?.value||60)};
} else if (t === 'guardian_event') {
return {
severity: document.getElementById('mb-tc-severity')?.value || '',
event_type: document.getElementById('mb-tc-etype')?.value || '',
};
} else if (t === 'email_keyword') {
const kw = (document.getElementById('mb-tc-keywords')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);
return {keywords: kw, category: document.getElementById('mb-tc-category')?.value||''};
}
return {};
}
function missionAddStep() {
_missionBuilderStepIdx++;
_missionBuilderSteps.push({id: _missionBuilderStepIdx, label:'', job_type:'ping', payload:'{}', continue_on_failure:0});
_renderBuilderSteps();
}
function missionRemoveStep(sid) {
_missionBuilderSteps = _missionBuilderSteps.filter(s => s.id !== sid);
_renderBuilderSteps();
}
function missionMoveStep(sid, dir) {
const idx = _missionBuilderSteps.findIndex(s => s.id === sid);
if (idx < 0) return;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= _missionBuilderSteps.length) return;
[_missionBuilderSteps[idx], _missionBuilderSteps[newIdx]] = [_missionBuilderSteps[newIdx], _missionBuilderSteps[idx]];
_renderBuilderSteps();
}
function _renderBuilderSteps() {
const el = document.getElementById('mb-steps');
if (!el) return;
if (!_missionBuilderSteps.length) {
el.innerHTML = '<div style="font-size:0.6rem;color:var(--dim);padding:8px 0">No steps yet. Click + ADD STEP.</div>';
return;
}
const typeOpts = JOB_TYPES.map(t => `<option value="${t}">${t}</option>`).join('');
el.innerHTML = _missionBuilderSteps.map((s, i) => `
<div id="step-card-${s.id}" style="border:1px solid var(--border);border-radius:4px;padding:10px 12px;margin-bottom:8px;background:rgba(0,212,255,0.02)">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
<span style="font-family:var(--mono);font-size:0.6rem;color:var(--dim);min-width:24px">0${i+1}</span>
<input class="inp" style="flex:2" placeholder="Step label" value="${esc(s.label)}" oninput="_stepUpdate(${s.id},'label',this.value)">
<select class="inp" style="flex:2" onchange="_stepUpdate(${s.id},'job_type',this.value)">
${JOB_TYPES.map(t=>`<option value="${t}"${s.job_type===t?' selected':''}>${t}</option>`).join('')}
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:0.55rem;color:var(--dim);white-space:nowrap">
<input type="checkbox" ${s.continue_on_failure?'checked':''} onchange="_stepUpdate(${s.id},'continue_on_failure',this.checked?1:0)"> CONTINUE IF FAIL
</label>
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},-1)">↑</button>
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},1)">↓</button>
<button class="btn btn-xs btn-red" onclick="missionRemoveStep(${s.id})">✗</button>
</div>
<div class="lbl">PAYLOAD (JSON — use {{step_0.field}} for prior results)</div>
<textarea class="inp" rows="3" style="font-family:var(--mono);font-size:0.6rem;resize:vertical" oninput="_stepUpdate(${s.id},'payload',this.value)">${esc(s.payload||'{}')}</textarea>
</div>
`).join('');
}
function _stepUpdate(sid, field, value) {
const s = _missionBuilderSteps.find(x => x.id === sid);
if (s) s[field] = value;
}
async function missionSave() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0) || null;
const name = document.getElementById('mb-name')?.value.trim();
const desc = document.getElementById('mb-desc')?.value.trim();
const ttype = document.getElementById('mb-trigger')?.value;
const tcfg = _readTriggerConfig();
const status = document.getElementById('mb-status');
if (!name) { if (status) status.textContent = '✗ Mission name is required'; return; }
const steps = _missionBuilderSteps.map((s, i) => {
let payload = {};
try { payload = JSON.parse(s.payload || '{}'); } catch(e) { payload = {}; }
return {label: s.label, job_type: s.job_type, payload, continue_on_failure: s.continue_on_failure};
});
const body = {name, description: desc, trigger_type: ttype, trigger_config: tcfg, enabled: 1, steps};
if (status) status.textContent = '◈ SAVING…';
const url = id ? `admin?action=mission_save&id=${id}` : 'admin?action=mission_save';
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).catch(() => ({error: 'request failed'}));
if (res.ok || res.id) {
if (status) status.textContent = '◈ SAVED ✓';
if (!id && res.id) document.getElementById('mb-mission-id').value = res.id;
document.getElementById('mb-run-btn').style.display = '';
document.getElementById('mb-del-btn').style.display = '';
toast('Mission saved', 'ok');
loadMissions();
} else {
if (status) status.textContent = '✗ Save failed: ' + (res.error || 'unknown');
toast('Save failed: ' + (res.error || ''), 'err');
}
}
async function missionRunNow(id) {
const d = await api('mission_run', {id});
if (d.run_id || d.status) {
const s = d.status || 'running';
const color = s === 'done' ? 'ok' : s === 'failed' ? 'err' : 'ok';
toast(`Mission run ${s} — Run #${d.run_id||'?'} (${d.steps||0} steps)`, color);
loadMissions();
} else {
toast('Run failed: ' + (d.error || 'Arc Reactor offline'), 'err');
}
}
async function missionRunFromBuilder() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
if (!id) { toast('Save the mission first', 'err'); return; }
const status = document.getElementById('mb-status');
if (status) status.textContent = '◈ RUNNING…';
await missionRunNow(id);
if (status) status.textContent = '◈ Run dispatched — check HISTORY';
setTimeout(() => missionViewRuns(id), 1500);
}
async function missionDeleteFromBuilder() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
if (!id || !confirm('Delete this mission and all its run history?')) return;
const d = await api('mission_delete', {id});
if (d.ok) {
toast('Mission deleted', 'ok');
document.getElementById('mission-builder').style.display = 'none';
document.getElementById('mission-run-history').style.display = 'none';
loadMissions();
} else {
toast('Delete failed', 'err');
}
}
async function missionViewRuns(id) {
const el = document.getElementById('mission-runs-tbl');
const box = document.getElementById('mission-run-history');
if (!el || !box) return;
box.style.display = 'block';
const runs = await api('mission_runs', {id, limit: 20});
const list = Array.isArray(runs) ? runs : [];
if (!list.length) { el.innerHTML = '<div class="loading">No runs yet.</div>'; return; }
const sColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)', cancelled:'var(--dim)'};
const rows = list.map(r => {
const sc = r.status || 'done';
const ts = r.started_at ? new Date(r.started_at+'Z').toLocaleString() : '—';
const dur = r.completed_at && r.started_at
? Math.round((new Date(r.completed_at) - new Date(r.started_at)) / 1000) + 's'
: '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.65rem">#${r.id}</td>
<td style="color:${sColor[sc]||'var(--text)'};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="font-size:0.6rem;color:var(--dim)">${esc(r.trigger_source||'manual')}</td>
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
<td style="font-size:0.6rem;color:var(--dim)">${dur}</td>
<td><button class="btn btn-xs" onclick="missionRunDetail(${r.id})">STEPS</button></td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>RUN</th><th>STATUS</th><th>TRIGGER</th><th>STARTED</th><th>DURATION</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
box.scrollIntoView({behavior:'smooth'});
}
async function missionRunDetail(runId) {
// Fetch from DB via a direct approach — get all runs and find this one
// We'll just show steps_log from the run using the mission_runs table
const res = await fetch(`admin?action=mission_runs&id=0&limit=200&run_id=${runId}`)
.then(r => r.json()).catch(() => ({}));
// Fallback: fetch all runs for the currently edited mission
const mid = parseInt(document.getElementById('mb-mission-id')?.value || 0);
const runs = mid ? await api('mission_runs', {id: mid, limit: 50}) : [];
const run = Array.isArray(runs) ? runs.find(r => r.id == runId) : null;
if (!run) { toast('Run details not available', 'err'); return; }
const steps = run.steps_log;
const list = Array.isArray(steps) ? steps : (typeof steps === 'string' ? JSON.parse(steps||'[]') : []);
const rows = list.map(s => {
const sc = s.status || 'done';
const sc_color = sc==='done'?'var(--green)':sc==='failed'?'var(--red)':'var(--orange)';
const result = s.result ? JSON.stringify(s.result).substring(0,120) : s.error || '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.6rem">${s.step+1}</td>
<td style="font-size:0.6rem">${esc(s.label||s.job_type)}</td>
<td style="font-size:0.6rem;color:var(--dim)">${esc(s.job_type)}</td>
<td style="color:${sc_color};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="font-size:0.58rem;color:var(--dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(result)}</td>
</tr>`;
}).join('');
openModal(`RUN #${runId} STEP LOG`, `
<table style="width:100%">
<thead><tr><th>#</th><th>LABEL</th><th>TYPE</th><th>STATUS</th><th>RESULT</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`, null, null);
}
async function missionToggle(id, enabled) {
const d = await api('mission_toggle', {id, enabled});
if (d.ok) loadMissions();
else toast('Toggle failed', 'err');
}
// ── PLANNER ─────────────────────────────────────────────────────────────────
const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};
+97
View File
@@ -948,6 +948,24 @@ body::after{
.comms-compose-field{width:100%;background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:3px;padding:6px 8px;color:var(--text);font-family:var(--font-mono);font-size:0.6rem;box-sizing:border-box;margin-bottom:7px}
.comms-compose-field:focus{outline:none;border-color:var(--cyan)}
.comms-compose-actions{display:flex;gap:6px;margin-top:8px}
/* ── MISSION OPS HUD ─────────────────────────────────────────────── */
.mission-card{background:rgba(0,212,255,0.03);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:7px;overflow:hidden}
.mission-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
.mission-card-head:hover{background:rgba(0,212,255,0.06)}
.mission-card-name{font-family:var(--font-display);font-size:0.6rem;letter-spacing:1px;color:var(--cyan);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mission-card-trigger{font-family:var(--font-mono);font-size:0.5rem;padding:2px 5px;border-radius:2px;color:var(--text-dim);border:1px solid rgba(255,255,255,0.1)}
.mission-card-body{display:none;padding:0 10px 10px;border-top:1px solid var(--panel-border)}
.mission-card.open .mission-card-body{display:block}
.mission-run-bar{display:flex;gap:5px;margin-top:8px}
.mission-run-btn{flex:1;background:rgba(0,212,255,0.08);border:1px solid rgba(0,212,255,0.3);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer}
.mission-run-btn:hover{background:rgba(0,212,255,0.15)}
.mission-run-btn:disabled{opacity:0.4;cursor:not-allowed}
.mission-run-item{display:flex;align-items:center;gap:6px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-family:var(--font-mono);font-size:0.52rem}
.mission-run-status.done{color:#00ff88}
.mission-run-status.failed{color:#ff2244}
.mission-run-status.running{color:#ffd700;animation:pulse 1.5s ease-in-out infinite}
.mission-new-btn{width:100%;background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.3);border-radius:4px;padding:5px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:2px;cursor:pointer;margin-bottom:7px}
.mission-new-btn:hover{background:rgba(0,212,255,0.12)}
/* ── INTEL PROTOCOL — research result cards ──────────────────────── */
.intel-card{background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.intel-card-head{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;user-select:none}
@@ -1174,6 +1192,7 @@ body::after{
<div class="tab" id="tab-btn-intel" onclick="switchTab('intel')">INTEL</div>
<div class="tab" id="tab-btn-comms" onclick="switchTab('comms')">COMMS</div>
<div class="tab" id="tab-btn-guardian" onclick="switchTab('guardian')">GUARDIAN</div>
<div class="tab" id="tab-btn-missions" onclick="switchTab('missions')">MISSIONS</div>
</div>
<div id="tab-vms" class="tab-pane" style="overflow-y:auto;flex:1">
<div id="vm-list"><div class="loading-shimmer"></div></div>
@@ -1201,6 +1220,9 @@ body::after{
<div id="tab-guardian" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="guardian-list"><div class="loading-shimmer"></div></div>
</div>
<div id="tab-missions" class="tab-pane" style="overflow-y:auto;flex:1;padding:4px 0">
<div id="missions-hud"><div class="loading-shimmer"></div></div>
</div>
</div>
</div>
@@ -3121,6 +3143,7 @@ function switchTab(name) {
if (name === 'intel') loadIntel();
if (name === 'comms') { loadComms(); loadCommsOutbox(); }
if (name === 'guardian') loadGuardian();
if (name === 'missions') loadMissionsHud();
if (name === 'alerts') loadAlerts();
}
@@ -4175,6 +4198,80 @@ async function _pollProactiveChat() {
} catch(e) {}
}
// ── MISSION OPS HUD ───────────────────────────────────────────────────────────
let _missionsOpenCards = new Set();
async function loadMissionsHud() {
const el = document.getElementById('missions-hud');
if (!el) return;
try {
const missions = await api('arc?action=missions');
const list = Array.isArray(missions) ? missions : [];
let html = '<button class="mission-new-btn" onclick="window.open(\'/admin#missions\',\'_blank\')">◈ MANAGE MISSIONS IN ADMIN</button>';
if (!list.length) {
html += '<div class="comms-empty">◈ NO MISSIONS<br><span style="opacity:0.5">Create workflows in Admin → Mission Ops</span></div>';
el.innerHTML = html;
return;
}
const trigIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
for (const m of list) {
const isOpen = _missionsOpenCards.has(m.id);
const icon = trigIcons[m.trigger_type] || '◈';
const enabled = m.enabled;
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleTimeString() : 'never';
html += `<div class="mission-card${isOpen?' open':''}" id="mission-card-${m.id}">
<div class="mission-card-head" onclick="toggleMissionCard(${m.id})">
<span style="opacity:${enabled?1:0.35}">${icon}</span>
<span class="mission-card-name" style="opacity:${enabled?1:0.45}">${escHtml(m.name)}</span>
<span class="mission-card-trigger">${m.trigger_type.replace('_',' ').toUpperCase()}</span>
<span style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${m.run_count||0} runs</span>
</div>
<div class="mission-card-body">
${m.description ? `<div style="font-size:0.58rem;color:var(--text-dim);margin:6px 0">${escHtml(m.description)}</div>` : ''}
<div style="font-family:var(--font-mono);font-size:0.52rem;color:var(--text-dim);margin:4px 0">Last run: ${lastRun} · ${m.run_count||0} total runs</div>
<div class="mission-run-bar">
<button class="mission-run-btn" id="mission-run-btn-${m.id}" onclick="hudRunMission(${m.id})"${!enabled?' disabled title="Mission disabled"':''}>▶ RUN NOW</button>
</div>
<div id="mission-run-result-${m.id}" style="font-family:var(--font-mono);font-size:0.52rem;margin-top:6px;min-height:12px"></div>
</div>
</div>`;
}
el.innerHTML = html;
} catch(e) {
if (el) el.innerHTML = '<div class="comms-empty">MISSIONS OFFLINE</div>';
}
}
function toggleMissionCard(id) {
const card = document.getElementById('mission-card-' + id);
if (!card) return;
if (_missionsOpenCards.has(id)) _missionsOpenCards.delete(id);
else _missionsOpenCards.add(id);
card.classList.toggle('open');
}
async function hudRunMission(id) {
const btn = document.getElementById('mission-run-btn-' + id);
const res = document.getElementById('mission-run-result-' + id);
if (btn) { btn.disabled = true; btn.textContent = '◈ RUNNING…'; }
if (res) res.textContent = '';
try {
const data = await api('arc?action=mission_run&id=' + id, 'POST', {trigger_source: 'hud'});
const s = data.status || 'done';
const color = s === 'done' ? '#00ff88' : s === 'failed' ? '#ff2244' : '#ffd700';
if (res) res.style.color = color;
if (res) res.textContent = `${s.toUpperCase()} — Run #${data.run_id||'?'} · ${data.steps||0} steps completed`;
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
setTimeout(loadMissionsHud, 2000);
} catch(e) {
if (btn) { btn.disabled = false; btn.textContent = '▶ RUN NOW'; }
if (res) res.textContent = '✗ Run failed';
}
}
async function loadAgents() {
const [listData, metricsData] = await Promise.all([
api('agent/list'),