diff --git a/src/observability/dashboard/state/feed.js b/src/observability/dashboard/state/feed.js index d686b0c..110c325 100644 --- a/src/observability/dashboard/state/feed.js +++ b/src/observability/dashboard/state/feed.js @@ -30,9 +30,12 @@ export function buildActivityFeed(runs) { for (const ev of run.events ?? []) { if (skipInFeed.has(ev.type)) continue; - const summary = plainLanguageSummary(ev); + // July harness vocabulary (#215) gets first-class summaries here; + // everything else keeps the shared plain-language path. Unknown event + // types still degrade exactly as before: no summary, no feed line. + const summary = opsEventSummary(ev) ?? plainLanguageSummary(ev); if (!summary) continue; - activityFeed.push({ + const item = { ts: ev.ts, summary, type: ev.type, @@ -40,7 +43,13 @@ export function buildActivityFeed(runs) { projectRoot: run.projectRoot, goal: run.manifest?.goal, level: eventLevel(ev), - }); + }; + // Structured detail for the client panels (retry state, guardrail + // depth, audit rejections) — only real fields from the event, never + // fabricated defaults. + const data = opsEventData(ev); + if (data) item.data = data; + activityFeed.push(item); } } @@ -49,6 +58,86 @@ export function buildActivityFeed(runs) { .slice(0, 200); } +// Plain-English lines for the loop-engineering signals shipped 2026-07 +// (#156 remainder + #215): checkpoints, context pressure, approval audit +// rejections, memory write decisions, metrics drift, retry decisions and +// goal-loop evaluations. Returns null for anything it does not own. +function opsEventSummary(ev) { + switch (ev.type) { + case 'stage_checkpoint_before_saved': + return checkpointSummary('before', ev); + case 'stage_checkpoint_after_saved': + return checkpointSummary('after', ev); + case 'context_pressure_warning': { + const size = fmtCount(ev.size); + const threshold = fmtCount(ev.threshold); + const metric = ev.metric ?? 'chars'; + return `Context pressure — ${ev.source ?? 'context'} at ${size} ${metric} (threshold ${threshold}); warning only, nothing was pruned`; + } + case 'approval_audit_failed': + return `Approval record rejected by audit — ${ev.artifact ?? 'unknown artifact'} treated as absent; the gate stayed closed`; + case 'episode_memory_written': + return `Episode memory written for ${ev.task_id ?? 'run'}${ev.trusted === true ? ' (trusted)' : ev.trusted === false ? ' (stored untrusted — never injected into prompts)' : ''}`; + case 'episode_memory_skipped_untrusted': + return `Episode memory skipped for ${ev.task_id ?? 'task'} — ${ev.reason ?? 'write policy refused the episode'}${ev.write_policy ? ` (policy: ${ev.write_policy})` : ''}`; + case 'metrics_write_failed': + return `Metrics write failed${ev.operation ? ` (${ev.operation})` : ''} — persisted totals are behind the events; run totals fall back to event recompute`; + case 'retry_decision': + // Pinned #123 emitter contract: { task_id, stage_id, attempt, + // max_attempts, retry_recommendation, action, next_status, reason, + // issues[] } with action ∈ complete|retry|exhausted|human_context|block. + return `Retry decision for ${ev.task_id ?? 'task'}: ${ev.action ?? '?'}${ev.next_status ? ` → ${ev.next_status}` : ''}${ev.reason ? ` — ${ev.reason}` : ''}`; + // goal_evaluated keeps its shared plain-language summary (goal_id / + // status / score / reason — the real pipeline-loop.js emitter fields); + // the ops layer only attaches the structured data below. + default: + return null; + } +} + +function checkpointSummary(phase, ev) { + const verified = ev.verified === true + ? 'verified restorable' + : ev.verified === false ? 'NOT verified — restore may fail' : 'verification unknown'; + return `Checkpoint saved ${phase} ${ev.stage_id ?? 'stage'} (${verified})`; +} + +function fmtCount(value) { + const num = Number(value); + return Number.isFinite(num) ? num.toLocaleString('en-US') : '?'; +} + +// Structured per-type detail carried on feed items so the Alerts & +// Guardrails / Approvals panels can render depth (limit vs value, attempt +// counts, audit issues) without a second data path. Only fields that exist +// on the event are copied — absent fields stay absent. +const OPS_EVENT_DATA_FIELDS = { + guardrail_triggered: ['task_id', 'limit_name', 'current_value', 'limit_value', 'reason'], + guardrail_overridden: ['task_id', 'artifact'], + task_retry_scheduled: ['task_id', 'stage_id', 'attempt', 'max_attempts', 'retry_recommendation', 'reason'], + task_retry_exhausted: ['task_id', 'stage_id', 'attempt', 'max_attempts', 'retry_recommendation', 'reason'], + task_human_context_required: ['task_id', 'stage_id', 'attempt', 'max_attempts', 'retry_recommendation', 'reason'], + retry_decision: ['task_id', 'stage_id', 'attempt', 'max_attempts', 'retry_recommendation', 'action', 'next_status', 'reason'], + context_pressure_warning: ['task_id', 'source', 'metric', 'size', 'threshold', 'blocking', 'stage_id'], + approval_audit_failed: ['record_id', 'artifact', 'status', 'issues', 'reason'], + stage_checkpoint_before_saved: ['stage_id', 'task_id', 'verified'], + stage_checkpoint_after_saved: ['stage_id', 'task_id', 'verified'], + episode_memory_written: ['task_id', 'trusted'], + episode_memory_skipped_untrusted: ['task_id', 'reason', 'write_policy'], + metrics_write_failed: ['task_id', 'operation', 'error'], + goal_evaluated: ['iteration', 'max_iterations', 'goal_id', 'status', 'score', 'critical_count', 'failing_stages', 'recommended_rerun_stages'], +}; + +function opsEventData(ev) { + const fields = OPS_EVENT_DATA_FIELDS[ev.type]; + if (!fields) return null; + const data = {}; + for (const field of fields) { + if (ev[field] !== undefined) data[field] = ev[field]; + } + return Object.keys(data).length ? data : null; +} + function eventLevel(ev) { if (ev.type === 'task_validated' && ev.status === 'FAIL') return 'fail'; if (ev.type === 'validation_failed') return 'fail'; @@ -59,7 +148,23 @@ function eventLevel(ev) { if (ev.type === 'task_retry_exhausted') return 'fail'; if (ev.type === 'task_human_context_required') return 'blocked'; if (ev.type === 'task_blocked_by_validator') return 'blocked'; + if (ev.type === 'retry_decision') { + // next_status is the task transition the harness actually made (#123): + // PASS | FAIL (re-claimable) | BLOCKED | NEEDS_CONTEXT. + const nextStatus = String(ev.next_status ?? '').toUpperCase(); + if (nextStatus === 'BLOCKED' || nextStatus === 'NEEDS_CONTEXT') return 'blocked'; + if (nextStatus === 'PASS') return 'pass'; + return 'warn'; + } if (/^retry_/.test(String(ev.type ?? ''))) return 'warn'; + if (ev.type === 'stage_checkpoint_before_saved' || ev.type === 'stage_checkpoint_after_saved') { + return ev.verified === false ? 'warn' : 'info'; + } + if (ev.type === 'context_pressure_warning') return 'warn'; + if (ev.type === 'approval_audit_failed') return 'fail'; + if (ev.type === 'episode_memory_written') return 'info'; + if (ev.type === 'episode_memory_skipped_untrusted') return 'warn'; + if (ev.type === 'metrics_write_failed') return 'warn'; if (ev.type === 'goal_evaluated') return ev.status === 'PASS' ? 'pass' : 'warn'; if (ev.type === 'loop_iteration_retrying_stages') return 'warn'; if (ev.type === 'loop_completed') return 'pass'; diff --git a/src/observability/dashboard/ui/pages/alerts-guardrails.js b/src/observability/dashboard/ui/pages/alerts-guardrails.js index 4dc6901..73ffb43 100644 --- a/src/observability/dashboard/ui/pages/alerts-guardrails.js +++ b/src/observability/dashboard/ui/pages/alerts-guardrails.js @@ -3,9 +3,34 @@ // Alerts & Guardrails page module — renders into #page-alerts-guardrails. Plain client JS // concatenated into the served bundle by ui/client.js; self-registers its // renderer with the page registry (ui/lib.js). +// +// Loop-engineering depth (#156 remainder + #215 ops slice): retry state per +// task, guardrail triggers with limit-vs-value and override status, and +// context-pressure warnings. All three panels are derived from the recent +// server-composed event feed plus task/approval state already in the +// snapshot — nothing is fabricated, and each panel says when it is empty. export const alertsGuardrailsScript = ` // ── page: alerts-guardrails ──────────────────────────────────────────────── +// Panel injection uses opsEnsureSection, declared in pages/live-feed.js — the +// earliest-concatenated wave:ops module in the served bundle. +var OPS_ALERTS_PANELS_HTML = + '
' + + '
Retry State
' + + '
' + + '
Derived from the recent event stream: retry scheduling, budget exhaustion and human-context pauses per task.
' + + '
' + + '
' + + '
' + + '
Guardrail Triggers
' + + '
' + + '
' + + '
' + + '
Context Pressure Warnings
' + + '
' + + '
' + + '
'; + function renderAlertsGuardrails(s) { var alerts = s.alerts || []; var blocked = s.blockedGates || []; @@ -15,15 +40,186 @@ function renderAlertsGuardrails(s) { setHTML('blocked-list', blocked.map(function(gate) { return '
' + esc(gate.title) + '
' + esc(gate.detail) + '
' + esc(gate.runId || '') + '' + esc(fmtTime(gate.ts)) + '
'; }).join('') || emptyHtml('No blocked gates', 'Blocked approval gate history appears here.')); + + opsEnsureSection('alerts-guardrails', 'ops-retry-panel', OPS_ALERTS_PANELS_HTML); + renderOpsRetryPanel(s); + renderOpsGuardrailPanel(s); + renderOpsPressurePanel(s); } function alertHtml(alert) { return '
' + esc(alert.title || alert.type || 'Alert') + '
' + esc(alert.detail || '') + '
' + esc(alert.type || '') + '' + esc(alert.runId || '') + '
' + pill(alert.level || 'info') + '
'; } +// ── Retry state (#156: retry traces) ──────────────────────────────────────── +var OPS_RETRY_EVENT_TYPES = { + task_retry_scheduled: 'scheduled', + task_retry_exhausted: 'exhausted', + task_human_context_required: 'human_required' +}; + +// retry_decision action → panel state (pinned #123 contract: +// action ∈ complete|retry|exhausted|human_context|block). +var OPS_DECISION_STATES = { + retry: 'scheduled', + exhausted: 'exhausted', + human_context: 'human_required', + block: 'validator_blocked', + complete: 'resolved' +}; + +function opsRetryStateLabel(state) { + if (state === 'scheduled') return pill('warn', 'retry scheduled'); + if (state === 'exhausted') return pill('fail', 'budget exhausted'); + if (state === 'human_required') return pill('blocked', 'human input required'); + if (state === 'validator_blocked') return pill('blocked', 'validator blocked'); + if (state === 'resolved') return pill('pass', 'resolved'); + if (state === 'blocked_unknown') return pill('blocked', 'blocked'); + return pill('info', state || 'unknown'); +} + +// Latest retry signal per task for one run, folded from the recent feed. +// Oldest-first; at EQUAL timestamps the action-specific event +// (task_retry_exhausted / _scheduled / _human_context_required) outranks the +// paired retry_decision — the emitter appends both in the same tick, decision +// first, and the specific event is the final word on the state. Without this +// tiebreak an exhausted task could render under the decision's mapping alone. +function opsRetryStateForRun(run, feed) { + var items = []; + feed.forEach(function(item) { + if (item.runId !== run.runId) return; + if (item.type !== 'retry_decision' && !OPS_RETRY_EVENT_TYPES[item.type]) return; + if (!item.data || !item.data.task_id) return; + items.push(item); + }); + items.sort(function(a, b) { + var byTs = String(a.ts || '').localeCompare(String(b.ts || '')); + if (byTs !== 0) return byTs; + return (a.type === 'retry_decision' ? 0 : 1) - (b.type === 'retry_decision' ? 0 : 1); + }); + var byTask = {}; + items.forEach(function(item) { + var d = item.data; + var prev = byTask[d.task_id] || {}; + if (item.type === 'retry_decision') { + byTask[d.task_id] = { + state: OPS_DECISION_STATES[String(d.action || '').toLowerCase()] || prev.state || 'unknown', + attempt: d.attempt != null ? d.attempt : prev.attempt, + maxAttempts: d.max_attempts != null ? d.max_attempts : prev.maxAttempts, + reason: d.reason || prev.reason, + action: d.action || prev.action, + nextStatus: d.next_status || prev.nextStatus, + ts: item.ts + }; + } else { + byTask[d.task_id] = { + state: OPS_RETRY_EVENT_TYPES[item.type], + attempt: d.attempt != null ? d.attempt : prev.attempt, + maxAttempts: d.max_attempts != null ? d.max_attempts : prev.maxAttempts, + reason: d.reason || prev.reason, + action: prev.action, + nextStatus: prev.nextStatus, + ts: item.ts + }; + } + }); + return byTask; +} + +function opsRetryRowHtml(run, task, info) { + var attempts = info && info.attempt != null + ? 'attempt ' + info.attempt + (info.maxAttempts != null ? '/' + info.maxAttempts : '') + : ''; + var decisionMeta = info && (info.action || info.nextStatus) + ? 'decision: ' + (info.action || '?') + (info.nextStatus ? ' → ' + info.nextStatus : '') + : ''; + return '
' + + '
' + esc(task.id) + (task.stageId ? ' (' + esc(task.stageId) + ')' : '') + '
' + + (info && info.reason ? '
' + esc(info.reason) + '
' : '') + + (!info ? '
Task is BLOCKED — cause outside the recent event window (validator, DoR, destructive-gate or attempt budget; no retry evidence in view).
' : '') + + '
' + esc((run.runId || '').slice(-16)) + '' + (attempts ? '' + esc(attempts) + '' : '') + (decisionMeta ? '' + esc(decisionMeta) + '' : '') + 'task status: ' + esc(task.status || 'READY') + '
' + + // "budget exhausted" is earned only by real task_retry_exhausted / + // exhausted-decision evidence — a BLOCKED task without in-window events + // gets the generic blocked pill, never a fabricated cause. + '
' + opsRetryStateLabel(info ? info.state : 'blocked_unknown') + '
'; +} + +function renderOpsRetryPanel(s) { + var feed = s.feed || []; + var rows = []; + (s.runs || []).forEach(function(run) { + var byTask = opsRetryStateForRun(run, feed); + (run.tasks || []).forEach(function(task) { + var info = byTask[task.id] || null; + if (!info && task.status !== 'BLOCKED') return; + rows.push(opsRetryRowHtml(run, task, info)); + }); + }); + setText('ops-retry-count', rows.length + ' task(s) in retry flow'); + setHTML('ops-retry-list', rows.join('') || emptyHtml('No retry activity', 'No task in the recent event window has needed a second attempt or hit its attempt budget.')); +} + +// ── Guardrail triggers + override status (#156: guardrail depth) ──────────── +function opsOverrideStatusHtml(runId, taskId, s) { + if (!taskId) return '
No task id on this trigger — cannot look up an override.
'; + var artifact = 'guardrail-override:' + taskId; + var consumed = (s.feed || []).some(function(item) { + return item.type === 'guardrail_overridden' && item.runId === runId && item.data && item.data.task_id === taskId; + }); + if (consumed) return '
Override consumed — exactly one extra attempt was granted, then the gate re-armed.
'; + var record = null; + (s.runs || []).forEach(function(run) { + if (run.runId !== runId) return; + (run.approvals || []).forEach(function(approval) { + if (approval.artifact === artifact) record = approval; + }); + }); + if (!record) return '
No override on file — the task stays blocked until a ' + esc(artifact) + ' approval is granted.
'; + var status = String(record.status || '').toUpperCase(); + if (status === 'APPROVED') return '
Override approved' + (record.approver ? ' by ' + esc(record.approver) : '') + ' — the next claim gets exactly one attempt.
'; + if (status === 'REJECTED') return '
Override rejected' + (record.approver ? ' by ' + esc(record.approver) : '') + ' — the task stays blocked.
'; + return '
Override request on file (status: ' + esc(record.status || 'pending') + ').
'; +} + +function renderOpsGuardrailPanel(s) { + var items = (s.feed || []).filter(function(item) { return item.type === 'guardrail_triggered'; }).slice(0, 20); + setText('ops-guardrail-count', items.length + ' trigger(s)'); + setHTML('ops-guardrail-list', items.map(function(item) { + var d = item.data || {}; + var limit = d.limit_name || 'guardrail'; + var values = d.current_value != null && d.limit_value != null + ? 'value ' + d.current_value + ' hit limit ' + d.limit_value + : (d.reason || 'limit reached'); + return '
' + + '
' + esc(limit) + '
' + + '
' + esc(values) + (d.task_id ? ' — task ' + esc(d.task_id) : '') + '
' + + '
' + esc((item.runId || '').slice(-16)) + '' + esc(fmtTime(item.ts)) + '
' + + opsOverrideStatusHtml(item.runId, d.task_id, s) + + '
' + pill('blocked', 'blocked') + '
'; + }).join('') || emptyHtml('No guardrail triggers', 'No guardrail has blocked a task in the recent event window.')); +} + +// ── Context pressure warnings (#215 / #211) ───────────────────────────────── +function renderOpsPressurePanel(s) { + var items = (s.feed || []).filter(function(item) { return item.type === 'context_pressure_warning'; }).slice(0, 20); + setText('ops-pressure-count', items.length + ' warning(s)'); + setHTML('ops-pressure-list', items.map(function(item) { + var d = item.data || {}; + var size = d.size != null && d.threshold != null + ? d.size + ' vs threshold ' + d.threshold + (d.metric ? ' ' + d.metric : '') + : 'size unavailable'; + return '
' + + '
' + esc(d.source || 'context') + '
' + + '
' + esc(size) + (d.task_id ? ' — task ' + esc(d.task_id) : '') + (d.stage_id ? ' (' + esc(d.stage_id) + ')' : '') + '
' + + '
' + esc((item.runId || '').slice(-16)) + '' + esc(fmtTime(item.ts)) + '
' + + '
Detect-only warning — nothing was pruned or truncated.
' + + '
' + pill('warn', 'pressure') + '
'; + }).join('') || emptyHtml('No context pressure warnings', 'Builder context stayed under every configured threshold in the recent event window.')); +} + registerPage('alerts-guardrails', { errLabel: 'alerts', - sub: 'Threshold alerts, blocked gates, guardrails, stalled work and spend risks.', + sub: 'Threshold alerts, blocked gates, guardrails, retry state, context pressure, stalled work and spend risks.', unscoped: true, render: renderAlertsGuardrails }); diff --git a/src/observability/dashboard/ui/pages/approvals.js b/src/observability/dashboard/ui/pages/approvals.js index a20b163..440e321 100644 --- a/src/observability/dashboard/ui/pages/approvals.js +++ b/src/observability/dashboard/ui/pages/approvals.js @@ -6,6 +6,19 @@ export const approvalsScript = ` // ── page: approvals ──────────────────────────────────────────────── +// Audit rejections (#215 / #133): approval records are a trust boundary — +// a record that fails the consistency audit is treated as absent and the +// gate stays closed. That rejection must be visible here, never silent: +// this panel is the tampering-visibility surface. +var OPS_AUDIT_PANEL_HTML = + '
' + + '
Audit Rejections
' + + '
' + + '
Every approval record is audited before it is trusted. A record listed here failed the consistency audit and was treated as absent — the gate stayed closed and the gated work did not proceed. A rejection can mean corruption, drift, or a forged record: review it before re-approving.
' + + '
' + + '
' + + '
'; + function renderApprovals(s) { var approvals = s.approvals || []; var pending = approvals.filter(function(item) { return !item.status || item.status === 'pending'; }); @@ -13,6 +26,28 @@ function renderApprovals(s) { setText('approvals-count', pending.length + ' pending'); setHTML('approvals-list', pending.map(function(item) { return approvalHtml(item, true); }).join('') || emptyHtml('No pending approvals', 'Only queue-backed approvals appear here.')); setHTML('approvals-resolved', resolved.slice(0, 20).map(function(item) { return approvalHtml(item, false); }).join('') || emptyHtml('No resolved approvals', 'Approved and rejected queue entries appear here.')); + opsEnsureSection('approvals', 'ops-audit-panel', OPS_AUDIT_PANEL_HTML); + renderOpsAuditRejections(s); +} + +function opsAuditRejectionHtml(item) { + var d = item.data || {}; + // This panel renders records that already failed a trust audit — the + // issues field itself is hostile input. A forged non-array value is shown + // as a single line instead of throwing into the page error banner. + var issues = Array.isArray(d.issues) ? d.issues : (d.issues != null ? [String(d.issues)] : []); + return '
' + + '
' + esc(d.artifact || 'unknown artifact') + '
' + + '
' + esc(d.reason || 'Record failed the consistency audit — treated as absent; the gate stayed closed.') + '
' + + (issues.length ? '
    ' + issues.map(function(issue) { return '
  • ' + esc(issue) + '
  • '; }).join('') + '
' : '') + + '
' + (d.record_id ? 'record ' + esc(d.record_id) + '' : '') + (d.status ? 'claimed status: ' + esc(d.status) + '' : '') + '' + esc((item.runId || '').slice(-16)) + '' + esc(fmtTime(item.ts)) + '
' + + '
' + pill('fail', 'rejected') + '
'; +} + +function renderOpsAuditRejections(s) { + var items = (s.feed || []).filter(function(item) { return item.type === 'approval_audit_failed'; }).slice(0, 20); + setText('ops-audit-count', items.length + ' rejection(s)'); + setHTML('ops-audit-list', items.map(opsAuditRejectionHtml).join('') || emptyHtml('No audit rejections', 'Every approval record read in the recent event window passed the consistency audit.')); } function approvalHtml(item, canAct) { diff --git a/src/observability/dashboard/ui/pages/live-feed.js b/src/observability/dashboard/ui/pages/live-feed.js index 5b0e605..c0b90b3 100644 --- a/src/observability/dashboard/ui/pages/live-feed.js +++ b/src/observability/dashboard/ui/pages/live-feed.js @@ -6,10 +6,93 @@ export const liveFeedScript = ` // ── page: live-feed ──────────────────────────────────────────────── +// [wave:ops] shared helper. Declared here because live-feed.js is the +// EARLIEST-concatenated of the wave:ops page modules in ui/client.js +// (live-feed → approvals → alerts-guardrails), so the later ops modules can +// call it without leaning on whole-bundle function hoisting. Injects a +// section once into a page body: page modules own their panels but not +// ui/pages/index.js, so late-added panels mount themselves on first render. +function opsEnsureSection(pageId, markerId, html) { + if (document.getElementById(markerId)) return; + var page = document.getElementById('page-' + pageId); + if (!page) return; + page.insertAdjacentHTML('beforeend', html); +} + +// July harness vocabulary (#215) gets distinct glyphs so the audit story +// reads at a glance: checkpoints (CP), context pressure (CX), approval audit +// (AU), memory writes (MB), metrics drift (MX), retries (RT), guardrails +// (GR), goal loop (GL). Anything not listed falls back to the shared +// level-based feed row — unknown event types keep degrading gracefully. +var OPS_FEED_ICONS = { + stage_checkpoint_before_saved: 'CP', + stage_checkpoint_after_saved: 'CP', + context_pressure_warning: 'CX', + approval_audit_failed: 'AU', + episode_memory_written: 'MB', + episode_memory_skipped_untrusted: 'MB', + metrics_write_failed: 'MX', + retry_decision: 'RT', + task_retry_scheduled: 'RT', + task_retry_exhausted: 'RT', + task_human_context_required: 'RT', + guardrail_triggered: 'GR', + guardrail_overridden: 'GR', + goal_evaluated: 'GL' +}; + +// One compact detail chip per event type, read from the structured data the +// server feed attaches (state/feed.js). Empty string when the event carried +// no detail — nothing is fabricated. +function opsFeedMeta(item) { + var d = item.data || {}; + switch (item.type) { + case 'guardrail_triggered': + if (!d.limit_name) return ''; + return d.limit_name + (d.current_value != null && d.limit_value != null ? ': ' + d.current_value + ' of ' + d.limit_value : ''); + case 'context_pressure_warning': + if (d.size == null || d.threshold == null) return ''; + return d.size + ' vs ' + d.threshold + (d.metric ? ' ' + d.metric : ''); + case 'stage_checkpoint_before_saved': + case 'stage_checkpoint_after_saved': + return d.verified === true ? 'verified' : d.verified === false ? 'unverified' : ''; + case 'approval_audit_failed': + // Forged records can carry a non-array issues field — count only real lists. + return Array.isArray(d.issues) && d.issues.length ? d.issues.length + ' audit issue(s)' : ''; + case 'retry_decision': + // Pinned #123 shape: action ∈ complete|retry|exhausted|human_context|block, + // next_status is the task transition the harness made. + if (!d.action && !d.next_status) return ''; + return (d.action || '?') + (d.next_status ? ' → ' + d.next_status : ''); + case 'task_retry_scheduled': + case 'task_retry_exhausted': + case 'task_human_context_required': + return d.attempt != null && d.max_attempts != null ? 'attempt ' + d.attempt + '/' + d.max_attempts : ''; + case 'episode_memory_written': + return d.trusted === true ? 'trusted' : d.trusted === false ? 'untrusted' : ''; + case 'episode_memory_skipped_untrusted': + return d.write_policy || ''; + case 'goal_evaluated': + // Real pipeline-loop.js fields: iteration/max_iterations + failing_stages. + if (d.iteration != null) return 'iteration ' + d.iteration + (d.max_iterations != null ? '/' + d.max_iterations : ''); + return Array.isArray(d.failing_stages) && d.failing_stages.length ? d.failing_stages.length + ' failing stage(s)' : ''; + default: + return ''; + } +} + +function opsFeedRowHtml(item) { + var icon = OPS_FEED_ICONS[item.type]; + if (!icon) return feedRowHtml(item); + var level = item.level || 'info'; + var extra = opsFeedMeta(item); + return '
' + esc(icon) + '
' + esc(item.summary || '') + '
' + (item.runId ? '' + esc(item.runId.slice(-14)) + '' : '') + (item.projectRoot ? '' + esc(shortName(item.projectRoot)) + '' : '') + (item.type ? '' + esc(item.type) + '' : '') + (extra ? '' + esc(extra) + '' : '') + '
' + esc(fmtTime(item.ts)) + '
'; +} + function renderLiveFeed(s) { var feed = s.feed || []; setText('live-feed-count', feed.length + ' events'); - setHTML('live-feed-list', feed.length ? feed.map(feedRowHtml).join('') : emptyHtml('No events yet', 'Live event data appears here.')); + setHTML('live-feed-list', feed.length ? feed.map(opsFeedRowHtml).join('') : emptyHtml('No events yet', 'Live event data appears here.')); } registerPage('live-feed', { diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js index 6d642ab..8fffa1a 100644 --- a/src/observability/dashboard/ui/styles.js +++ b/src/observability/dashboard/ui/styles.js @@ -1269,4 +1269,13 @@ tr.clickable:hover td { background: #f8fbff; } .kv-note { font-size: 11px; color: var(--muted); margin-top: 8px; font-style: italic; } @media (max-width: 900px) { .report-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* [wave:ops] — ops panels: retry state, guardrail depth, context pressure, audit rejections */ +.feed-icon.info { color: var(--blue); background: #eff6ff; } +.ops-meta { color: var(--amber); font-weight: 700; } +.ops-note { font-size: 11px; color: var(--muted); font-style: italic; margin-top: 8px; } +.ops-issues { margin: 6px 0 0; padding-left: 18px; font-size: 12px; color: var(--muted); } +.ops-issues li { padding: 2px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.alert-card.fail { border-left: 4px solid var(--red); } +/* [/wave:ops] */ `; diff --git a/tests/dashboard-ops-signals.test.js b/tests/dashboard-ops-signals.test.js new file mode 100644 index 0000000..e0460cc --- /dev/null +++ b/tests/dashboard-ops-signals.test.js @@ -0,0 +1,357 @@ +/** + * Ops surfaces for the July harness signals (#156 remainder + #215): + * - state/feed.js renders plain-English lines (+ structured data) for the + * new event vocabulary: checkpoints, context pressure, approval audit + * rejections, memory write decisions, metrics drift, retry decisions and + * goal evaluations — while unknown event types keep degrading gracefully; + * - pages/alerts-guardrails.js renders the Retry State / Guardrail Triggers / + * Context Pressure panels from fixture-shaped state; + * - pages/approvals.js renders the Audit Rejections panel (a rejected or + * forged approval record must be visible, never silent); + * - every panel has an honest empty state. + * + * The page renderers are real client code living inside template literals, + * so these tests execute them against a minimal DOM stub instead of only + * string-matching the source. + * + * owner: RStack developed by Richardson Gunde + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildActivityFeed } from '../src/observability/dashboard/state/feed.js'; +import { libScript } from '../src/observability/dashboard/ui/lib.js'; +import { liveFeedScript } from '../src/observability/dashboard/ui/pages/live-feed.js'; +import { approvalsScript } from '../src/observability/dashboard/ui/pages/approvals.js'; +import { alertsGuardrailsScript } from '../src/observability/dashboard/ui/pages/alerts-guardrails.js'; + +// Fixture-shaped events — field-for-field the shapes the harness emits +// (mirrors .rstack/runs/…-ui-fixture-july-signals/events.jsonl + HARNESS.md). +const JULY_EVENTS = [ + { type: 'stage_checkpoint_before_saved', stage_id: '06-architecture', task_id: '003-architecture', verified: true, ts: '2026-07-06T12:01:05.000Z' }, + { type: 'stage_checkpoint_after_saved', stage_id: '06-architecture', task_id: '003-architecture', verified: false, ts: '2026-07-06T12:10:05.000Z' }, + { type: 'context_pressure_warning', source: 'memory_summary', metric: 'chars', size: 61000, threshold: 40000, blocking: false, task_id: '004-implementation', ts: '2026-07-06T12:13:00.000Z' }, + { type: 'task_retry_scheduled', task_id: '005-testing', attempt: 1, max_attempts: 2, reason: 'validator requested another attempt', ts: '2026-07-06T12:31:00.000Z' }, + { type: 'retry_decision', task_id: '005-testing', stage_id: '08-testing', attempt: 2, max_attempts: 2, retry_recommendation: 'retry_builder', action: 'exhausted', next_status: 'BLOCKED', reason: 'attempt budget exhausted', issues: [], ts: '2026-07-06T12:35:00.000Z' }, + { type: 'guardrail_triggered', task_id: '005-testing', limit_name: 'maxTaskAttempts', current_value: 2, limit_value: 2, ts: '2026-07-06T12:35:01.000Z' }, + { type: 'approval_audit_failed', record_id: 'forged-1', artifact: 'guardrail-override:005-testing', status: 'APPROVED', issues: ['approval_actor_present: approver missing or empty'], reason: 'approval record failed the consistency audit — treated as absent, gated work stays gated', ts: '2026-07-06T12:36:00.000Z' }, + { type: 'episode_memory_written', task_id: '003-architecture', trusted: true, ts: '2026-07-06T12:37:00.000Z' }, + { type: 'episode_memory_skipped_untrusted', task_id: '005-testing', reason: 'validator status FAIL under validator-approved-only', write_policy: 'validator-approved-only', ts: '2026-07-06T12:38:00.000Z' }, + { type: 'metrics_write_failed', task_id: '004-implementation', operation: 'telemetry_increment', error: 'EACCES', ts: '2026-07-06T12:39:00.000Z' }, + { type: 'goal_evaluated', iteration: 1, max_iterations: 3, goal_id: 'ship-the-mvp', status: 'FAIL', score: 0.5, critical_count: 1, failing_stages: ['08-testing'], recommended_rerun_stages: ['07-code', '08-testing'], reason: 'test coverage below target', ts: '2026-07-06T12:40:00.000Z' }, + // Unknown event type: must not throw and must not fabricate a feed line. + { type: 'mystery_signal_from_the_future', task_id: '005-testing', ts: '2026-07-06T12:41:00.000Z' }, +]; + +const FIXTURE_RUN = { + runId: 'run-july-signals', + projectRoot: '/tmp/project', + manifest: { goal: 'July signals fixture' }, + events: JULY_EVENTS, +}; + +function fixtureState() { + return { + runs: [{ + runId: 'run-july-signals', + projectRoot: '/tmp/project', + manifest: { goal: 'July signals fixture' }, + tasks: [ + { id: '003-architecture', status: 'PASS', stageId: '06-architecture' }, + { id: '004-implementation', status: 'IN_PROGRESS', stageId: '07-code' }, + { id: '005-testing', status: 'BLOCKED', stageId: '08-testing' }, + ], + // No guardrail-override approval on file — the forged one was rejected. + approvals: [{ artifact: 'plan.md', status: 'APPROVED', approver: 'Richardson', timestamp: '2026-07-06T12:05:00.000Z' }], + }], + feed: buildActivityFeed([FIXTURE_RUN]), + alerts: [], + blockedGates: [], + approvals: [], + }; +} + +// ── Minimal DOM stub: just enough for setText/setHTML/insertAdjacentHTML ──── +function createDom(ids) { + const els = new Map(); + const makeEl = (id) => ({ + id, + innerHTML: '', + textContent: '', + insertAdjacentHTML(position, html) { + this.innerHTML += html; + // Register every id introduced by the injected markup so subsequent + // setHTML/setText calls can resolve the new panel containers. + for (const match of html.matchAll(/id="([^"]+)"/g)) { + if (!els.has(match[1])) els.set(match[1], makeEl(match[1])); + } + }, + }); + for (const id of ids) els.set(id, makeEl(id)); + return { + els, + document: { getElementById: (id) => els.get(id) ?? null }, + }; +} + +const PAGE_IDS = [ + 'page-alerts-guardrails', 'page-approvals', + 'alerts-count', 'blocked-count', 'alerts-list', 'blocked-list', + 'approvals-count', 'approvals-list', 'approvals-resolved', + 'live-feed-count', 'live-feed-list', +]; + +function loadRenderers(document) { + const factory = new Function( + 'document', 'window', 'localStorage', 'sessionStorage', 'fetch', + `${libScript}\n${liveFeedScript}\n${approvalsScript}\n${alertsGuardrailsScript}\n` + + 'return { renderLiveFeed: renderLiveFeed, renderApprovals: renderApprovals, renderAlertsGuardrails: renderAlertsGuardrails, opsFeedRowHtml: opsFeedRowHtml };', + ); + return factory(document, {}, {}, {}, () => { throw new Error('no network in tests'); }); +} + +// ── state/feed.js: new event vocabulary ───────────────────────────────────── + +test('feed renders a line for every July signal event type, with honest wording', () => { + const feed = buildActivityFeed([FIXTURE_RUN]); + const byType = Object.fromEntries(feed.map((entry) => [entry.type, entry])); + + assert.match(byType.stage_checkpoint_before_saved.summary, /Checkpoint saved before 06-architecture \(verified restorable\)/); + assert.match(byType.stage_checkpoint_after_saved.summary, /Checkpoint saved after 06-architecture \(NOT verified — restore may fail\)/); + assert.match(byType.context_pressure_warning.summary, /Context pressure — memory_summary at 61,000 chars \(threshold 40,000\)/); + // Detect-only honesty (#136): the warning must not claim pruning happened. + assert.match(byType.context_pressure_warning.summary, /warning only, nothing was pruned/); + assert.match(byType.approval_audit_failed.summary, /Approval record rejected by audit — guardrail-override:005-testing treated as absent; the gate stayed closed/); + assert.match(byType.episode_memory_written.summary, /Episode memory written for 003-architecture \(trusted\)/); + assert.match(byType.episode_memory_skipped_untrusted.summary, /Episode memory skipped for 005-testing — validator status FAIL under validator-approved-only \(policy: validator-approved-only\)/); + assert.match(byType.metrics_write_failed.summary, /Metrics write failed \(telemetry_increment\) — persisted totals are behind the events/); + assert.match(byType.retry_decision.summary, /Retry decision for 005-testing: exhausted → BLOCKED — attempt budget exhausted/); + assert.match(byType.goal_evaluated.summary, /🎯 Goal ship-the-mvp evaluated: FAIL \(score 0\.5\) — test coverage below target/); + assert.match(byType.task_retry_scheduled.summary, /Retry 1\/2 scheduled — 005-testing/); + + // Unknown types keep degrading exactly as before: dropped, never invented. + assert.equal(byType.mystery_signal_from_the_future, undefined); +}); + +test('feed levels for the new vocabulary distinguish blocked, failed and warning states', () => { + const feed = buildActivityFeed([FIXTURE_RUN]); + const levels = Object.fromEntries(feed.map((entry) => [entry.type, entry.level])); + assert.equal(levels.stage_checkpoint_before_saved, 'info'); + assert.equal(levels.stage_checkpoint_after_saved, 'warn'); // verified: false + assert.equal(levels.context_pressure_warning, 'warn'); + assert.equal(levels.approval_audit_failed, 'fail'); + assert.equal(levels.episode_memory_written, 'info'); + assert.equal(levels.episode_memory_skipped_untrusted, 'warn'); + assert.equal(levels.metrics_write_failed, 'warn'); + assert.equal(levels.retry_decision, 'blocked'); // next_status: BLOCKED + assert.equal(levels.goal_evaluated, 'warn'); // status: FAIL +}); + +test('goal_evaluated reads pass/fail from the real pipeline-loop status field', () => { + // pipeline-loop.js emits { iteration, max_iterations, goal_id, status, + // score, critical_count, failing_stages, recommended_rerun_stages, reason }. + // status is the source of truth — PASS reads green, anything else warns. + const feed = buildActivityFeed([{ + runId: 'run-goal', projectRoot: '/tmp/p', manifest: { goal: 'g' }, + events: [ + { type: 'goal_evaluated', iteration: 3, max_iterations: 3, goal_id: 'met-goal', status: 'PASS', score: 0.95, critical_count: 0, failing_stages: [], reason: 'all criteria met', ts: '2026-07-06T13:00:00.000Z' }, + { type: 'goal_evaluated', iteration: 1, max_iterations: 3, goal_id: 'early-goal', status: 'FAIL', score: 0.4, critical_count: 2, failing_stages: ['07-code'], reason: 'criteria unmet', ts: '2026-07-06T13:01:00.000Z' }, + ], + }]); + const met = feed.find((entry) => entry.summary.includes('met-goal')); + assert.match(met.summary, /🎯 Goal met-goal evaluated: PASS \(score 0\.95\)/); + assert.equal(met.level, 'pass'); + const early = feed.find((entry) => entry.summary.includes('early-goal')); + assert.match(early.summary, /🎯 Goal early-goal evaluated: FAIL \(score 0\.4\)/); + assert.equal(early.level, 'warn'); +}); + +test('feed items carry structured data for the ops panels — only fields the event had', () => { + const feed = buildActivityFeed([FIXTURE_RUN]); + const byType = Object.fromEntries(feed.map((entry) => [entry.type, entry])); + + assert.deepEqual(byType.guardrail_triggered.data, { + task_id: '005-testing', limit_name: 'maxTaskAttempts', current_value: 2, limit_value: 2, + }); + assert.deepEqual(byType.approval_audit_failed.data.issues, ['approval_actor_present: approver missing or empty']); + assert.equal(byType.approval_audit_failed.data.artifact, 'guardrail-override:005-testing'); + assert.equal(byType.context_pressure_warning.data.size, 61000); + assert.equal(byType.context_pressure_warning.data.threshold, 40000); + assert.equal(byType.context_pressure_warning.data.blocking, false); + assert.equal(byType.task_retry_scheduled.data.attempt, 1); + assert.equal(byType.task_retry_scheduled.data.max_attempts, 2); + assert.equal(byType.stage_checkpoint_after_saved.data.verified, false); + // Absent fields stay absent — nothing fabricated. + assert.ok(!('stage_id' in byType.context_pressure_warning.data)); +}); + +// ── Live Feed page: distinct glyphs, graceful fallback ────────────────────── + +test('live feed renders type-specific glyphs for every new event type', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderLiveFeed } = loadRenderers(document); + renderLiveFeed(fixtureState()); + const html = els.get('live-feed-list').innerHTML; + for (const glyph of ['CP', 'CX', 'AU', 'MB', 'MX', 'RT', 'GR', 'GL']) { + assert.ok(html.includes(`>${glyph}<`), `live feed shows the ${glyph} glyph`); + } + // Structured meta chips render from real data. + assert.match(html, /maxTaskAttempts: 2 of 2/); + assert.match(html, /61000 vs 40000 chars/); + assert.match(html, /1 audit issue\(s\)/); + assert.match(html, /attempt 1\/2/); +}); + +test('live feed keeps rendering unknown feed item types through the shared row', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderLiveFeed } = loadRenderers(document); + const state = fixtureState(); + state.feed = [{ ts: '2026-07-06T14:00:00.000Z', summary: 'something new happened', type: 'brand_new_event', runId: 'run-july-signals', level: 'info' }]; + assert.doesNotThrow(() => renderLiveFeed(state)); + assert.match(els.get('live-feed-list').innerHTML, /something new happened/); +}); + +// ── Alerts & Guardrails page: retry state, guardrail depth, pressure ──────── + +test('retry state panel shows the blocked task with attempts, decision and reason', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderAlertsGuardrails } = loadRenderers(document); + renderAlertsGuardrails(fixtureState()); + + const html = els.get('ops-retry-list').innerHTML; + assert.match(html, /005-testing/); + assert.match(html, /08-testing/); + assert.match(html, /budget exhausted/); // action: exhausted wins as the latest state + assert.match(html, /attempt 2\/2/); // the exhausted decision carries the final attempt count + assert.match(html, /decision: exhausted → BLOCKED/); + assert.match(html, /attempt budget exhausted/); + assert.match(html, /task status: BLOCKED/); + assert.equal(els.get('ops-retry-count').textContent, '1 task(s) in retry flow'); + // Healthy tasks stay out of the retry panel. + assert.ok(!html.includes('003-architecture')); +}); + +test('retry panel distinguishes a validator block from budget exhaustion — no fabricated cause', () => { + // Two BLOCKED tasks the harness reports with different retry_decision actions: + // 005 exhausted its attempt budget (action: exhausted → "budget exhausted") + // 006 was blocked by the validator (action: block → "validator blocked") + // Both carry next_status BLOCKED; keying on next_status alone would mislabel + // the validator block as budget exhaustion. The panel must not. + const events = [ + { type: 'retry_decision', task_id: '005-testing', stage_id: '08-testing', attempt: 2, max_attempts: 2, retry_recommendation: 'retry_builder', action: 'exhausted', next_status: 'BLOCKED', reason: 'attempt budget exhausted', ts: '2026-07-06T12:35:00.000Z' }, + { type: 'retry_decision', task_id: '006-deploy', stage_id: '09-deployment', attempt: 1, max_attempts: 2, retry_recommendation: 'block', action: 'block', next_status: 'BLOCKED', reason: 'validator blocked — a human decision is required', ts: '2026-07-06T12:36:00.000Z' }, + ]; + const state = fixtureState(); + state.runs[0].tasks.push({ id: '006-deploy', status: 'BLOCKED', stageId: '09-deployment' }); + state.feed = buildActivityFeed([{ ...FIXTURE_RUN, events }]); + + const { document, els } = createDom(PAGE_IDS); + loadRenderers(document).renderAlertsGuardrails(state); + const html = els.get('ops-retry-list').innerHTML; + + const i5 = html.indexOf('005-testing'); + const i6 = html.indexOf('006-deploy'); + assert.ok(i5 !== -1 && i6 !== -1 && i5 < i6, 'both blocked tasks render, in task order'); + const exhaustedCard = html.slice(i5, i6); + const validatorCard = html.slice(i6); + assert.match(exhaustedCard, /budget exhausted/); + assert.match(validatorCard, /validator blocked/); + // The validator block is never dressed up as a budget-exhaustion cause. + assert.ok(!validatorCard.includes('budget exhausted'), 'a validator block must not be labelled budget exhausted'); +}); + +test('guardrail panel shows limit vs value and says plainly no override is on file', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderAlertsGuardrails } = loadRenderers(document); + renderAlertsGuardrails(fixtureState()); + + const html = els.get('ops-guardrail-list').innerHTML; + assert.match(html, /maxTaskAttempts/); + assert.match(html, /value 2 hit limit 2/); + assert.match(html, /task 005-testing/); + assert.match(html, /No override on file — the task stays blocked until a guardrail-override:005-testing approval is granted\./); +}); + +test('guardrail panel reports an approved override and a consumed override honestly', () => { + const { document: docApproved, els: elsApproved } = createDom(PAGE_IDS); + const approvedState = fixtureState(); + approvedState.runs[0].approvals.push({ artifact: 'guardrail-override:005-testing', status: 'APPROVED', approver: 'Richardson', timestamp: '2026-07-06T12:50:00.000Z' }); + loadRenderers(docApproved).renderAlertsGuardrails(approvedState); + assert.match(elsApproved.get('ops-guardrail-list').innerHTML, /Override approved by Richardson — the next claim gets exactly one attempt\./); + + const { document: docConsumed, els: elsConsumed } = createDom(PAGE_IDS); + const consumedState = fixtureState(); + consumedState.feed = buildActivityFeed([{ + ...FIXTURE_RUN, + events: [...JULY_EVENTS, { type: 'guardrail_overridden', task_id: '005-testing', artifact: 'guardrail-override:005-testing', ts: '2026-07-06T12:55:00.000Z' }], + }]); + loadRenderers(docConsumed).renderAlertsGuardrails(consumedState); + assert.match(elsConsumed.get('ops-guardrail-list').innerHTML, /Override consumed — exactly one extra attempt was granted, then the gate re-armed\./); +}); + +test('context pressure panel lists source, size vs threshold, and the detect-only note', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderAlertsGuardrails } = loadRenderers(document); + renderAlertsGuardrails(fixtureState()); + + const html = els.get('ops-pressure-list').innerHTML; + assert.match(html, /memory_summary/); + assert.match(html, /61000 vs threshold 40000 chars/); + assert.match(html, /task 004-implementation/); + assert.match(html, /Detect-only warning — nothing was pruned or truncated\./); + assert.equal(els.get('ops-pressure-count').textContent, '1 warning(s)'); +}); + +// ── Approvals page: audit rejections (tampering visibility) ───────────────── + +test('audit rejections panel renders the rejected record with artifact, reason and issues', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderApprovals } = loadRenderers(document); + renderApprovals(fixtureState()); + + // The static explainer ships with the injected panel markup on the page body. + const panel = els.get('page-approvals').innerHTML; + assert.match(panel, /failed the consistency audit and was treated as absent — the gate stayed closed/); + + const html = els.get('ops-audit-list').innerHTML; + assert.match(html, /guardrail-override:005-testing/); + assert.match(html, /approval_actor_present: approver missing or empty/); + assert.match(html, /treated as absent, gated work stays gated/); + assert.match(html, /record forged-1/); + assert.match(html, /claimed status: APPROVED/); + assert.match(html, /rejected/); + assert.equal(els.get('ops-audit-count').textContent, '1 rejection(s)'); +}); + +// ── Empty states: no data is stated, never faked ──────────────────────────── + +test('every ops panel holds an honest empty state when there is nothing to show', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderLiveFeed, renderApprovals, renderAlertsGuardrails } = loadRenderers(document); + const empty = { runs: [], feed: [], alerts: [], blockedGates: [], approvals: [] }; + + renderLiveFeed(empty); + renderApprovals(empty); + renderAlertsGuardrails(empty); + + assert.match(els.get('live-feed-list').innerHTML, /No events yet/); + assert.match(els.get('ops-retry-list').innerHTML, /No retry activity/); + assert.match(els.get('ops-guardrail-list').innerHTML, /No guardrail triggers/); + assert.match(els.get('ops-pressure-list').innerHTML, /No context pressure warnings/); + assert.match(els.get('ops-audit-list').innerHTML, /No audit rejections/); + assert.equal(els.get('ops-retry-count').textContent, '0 task(s) in retry flow'); +}); + +test('panels self-mount exactly once across re-renders', () => { + const { document, els } = createDom(PAGE_IDS); + const { renderAlertsGuardrails, renderApprovals } = loadRenderers(document); + const state = fixtureState(); + renderAlertsGuardrails(state); + renderAlertsGuardrails(state); + renderApprovals(state); + renderApprovals(state); + const alertsPage = els.get('page-alerts-guardrails').innerHTML; + const approvalsPage = els.get('page-approvals').innerHTML; + assert.equal(alertsPage.split('id="ops-retry-panel"').length, 2, 'retry panel injected once'); + assert.equal(approvalsPage.split('id="ops-audit-panel"').length, 2, 'audit panel injected once'); +});