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
+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)'};