diff --git a/README.md b/README.md index 911ca08..d13b9fc 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ Reusable prompt components and skills are **phrases**. A phrase might be a CLAUD - `workflow_complete` hooks for deterministic chaining - Auto-reclaim of newly-started `persistent = false` sessions at workflow end - `tt serve` local control API endpoints: - - Reads: `/v1/health`, `/v1/status`, `/v1/voices`, `/v1/workflows`, `/v1/runs`, `/v1/logs`, `/v1/handoffs`, `/v1/policy-decisions`, `/v1/events` + - Reads: `/v1/health`, `/v1/status`, `/v1/voices`, `/v1/workflows`, `/v1/runs`, `/v1/ops`, `/v1/logs`, `/v1/handoffs`, `/v1/policy-decisions`, `/v1/events` - Event cursor/list filter: `/v1/events?cursor=&workspace=` - SSE stream: `/v1/events/stream?cursor=&workspace=` - Stream emits lifecycle/control events (`agent.started`, `agent.stopped`, `agent.working`, `agent.idle`, `agent.auth_failed`, `workflow.started`, `workflow.completed`, `workflow.failed`, handoff events) diff --git a/dashboard/app.js b/dashboard/app.js index 6e5c83e..84062e3 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -22,6 +22,7 @@ var appState = { selectedAgent: null, // composite key of currently selected agent selectedRun: null, // correlation_id of currently selected run runs: {}, // correlation_id -> run state { stage, status, steps, workflow_name } + ops: null, // /v1/ops summary for the operator console }; // ── Run tracking ── @@ -165,6 +166,10 @@ var $detailName = document.getElementById("detail-name"); var $detailMeta = document.getElementById("detail-meta"); var $detailEvts = document.getElementById("detail-events"); var $detailClose = document.getElementById("detail-close"); +var $opsConsole = document.getElementById("operator-console"); +var $opsSummary = document.getElementById("ops-summary"); +var $opsRuns = document.getElementById("ops-runs"); +var $opsEvents = document.getElementById("ops-events"); // ── Classify agent state into a CSS class ── function stateClass(agent) { @@ -537,6 +542,139 @@ function renderTimeline() { } } +// ── Operator console ── +function fetchOps() { + if (!$opsConsole) return Promise.resolve(); + return fetch("/v1/ops").then(function(res) { + if (!res.ok) throw new Error("ops endpoint returned " + res.status); + return res.json(); + }).then(function(json) { + appState.ops = json.data || null; + renderOperatorConsole(); + }).catch(function(e) { + console.warn("ops fetch failed:", e); + appState.ops = null; + renderOperatorConsole(); + }); +} + +function renderOperatorConsole() { + if (!$opsConsole || !$opsSummary || !$opsRuns || !$opsEvents) return; + var ops = appState.ops; + while ($opsSummary.firstChild) $opsSummary.removeChild($opsSummary.firstChild); + while ($opsRuns.firstChild) $opsRuns.removeChild($opsRuns.firstChild); + while ($opsEvents.firstChild) $opsEvents.removeChild($opsEvents.firstChild); + + if (!ops) { + $opsRuns.appendChild(el("div", "ops-empty", "loading…")); + return; + } + + var summary = ops.summary || {}; + $opsSummary.appendChild(opsChip("runs", summary.active_runs || 0, "")); + $opsSummary.appendChild(opsChip("blocked", summary.blocked_runs || 0, summary.blocked_runs ? "danger" : "")); + $opsSummary.appendChild(opsChip("review", summary.review_waiting || 0, summary.review_waiting ? "warn" : "")); + $opsSummary.appendChild(opsChip("gate", summary.gate_ready || 0, summary.gate_ready ? "ok" : "")); + + var runs = ops.runs || []; + if (runs.length === 0) { + $opsRuns.appendChild(el("div", "ops-empty", "no active runs")); + } else { + for (var i = 0; i < Math.min(runs.length, 6); i++) { + $opsRuns.appendChild(renderOpsRun(runs[i])); + } + } + + var events = ops.recent_events || []; + if (events.length === 0) { + $opsEvents.appendChild(el("li", null, "no ops events")); + } else { + for (var j = 0; j < Math.min(events.length, 8); j++) { + var evt = events[j]; + var li = document.createElement("li"); + li.appendChild(el("span", "evt-type", evt.event || "event")); + if (evt.agent) { + li.appendChild(document.createTextNode(" ")); + li.appendChild(el("span", "evt-agent", evt.agent)); + } + li.appendChild(el("span", "evt-time", timeAgo(evt.timestamp))); + $opsEvents.appendChild(li); + } + } +} + +function opsChip(label, value, className) { + var chip = el("span", "ops-chip" + (className ? " " + className : "")); + chip.appendChild(el("span", "ops-chip-value", String(value))); + chip.appendChild(el("span", "ops-chip-label", label)); + return chip; +} + +function renderOpsRun(run) { + var card = el("article", "ops-run " + opsRunClass(run)); + var top = el("div", "ops-run-top"); + var issue = run.issue || {}; + var title = "#" + (issue.number || "—"); + if (issue.title) title += " " + issue.title; + top.appendChild(el("h3", null, title)); + top.appendChild(el("span", "ops-stage", run.stage || "run")); + card.appendChild(top); + + var meta = el("div", "ops-run-meta"); + meta.appendChild(el("span", null, run.workflow || "workflow")); + if (run.branch) meta.appendChild(el("span", null, run.branch)); + if (run.updated_at) meta.appendChild(el("span", null, timeAgo(run.updated_at))); + card.appendChild(meta); + + var states = el("div", "ops-state-row"); + states.appendChild(opsStatePill("state", run.state || "unknown")); + states.appendChild(opsStatePill("review", (run.review && run.review.state) || "not_started")); + states.appendChild(opsStatePill("gate", (run.gate && run.gate.state) || "not_started")); + card.appendChild(states); + + if (run.artifact && run.artifact.name) { + var artifact = el("div", "ops-artifact"); + artifact.appendChild(el("span", "ops-artifact-label", "artifact")); + artifact.appendChild(el("span", null, run.artifact.name + " · " + (run.artifact.status || "pending"))); + card.appendChild(artifact); + } + + if (run.blocker) { + var blocker = el("div", "ops-blocker"); + blocker.appendChild(el("span", "ops-blocker-label", run.blocker.category || "blocked")); + blocker.appendChild(document.createTextNode(" ")); + blocker.appendChild(el("span", null, run.blocker.message || "blocked")); + card.appendChild(blocker); + } + + var action = el("div", "ops-next", run.next_action || "No action required"); + card.appendChild(action); + return card; +} + +function opsStatePill(label, value) { + var pill = el("span", "ops-pill " + opsValueClass(value)); + pill.appendChild(el("span", "ops-pill-label", label)); + pill.appendChild(el("span", null, value.replace(/_/g, " "))); + return pill; +} + +function opsRunClass(run) { + if (run.blocker) return "blocked"; + var gate = run.gate && run.gate.state; + if (gate === "ready" || gate === "merged") return "ready"; + var review = run.review && run.review.state; + if (review === "waiting" || gate === "checking") return "waiting"; + return "active"; +} + +function opsValueClass(value) { + if (value === "blocked" || value === "failed") return "danger"; + if (value === "waiting" || value === "checking" || value === "pr_open") return "warn"; + if (value === "ready" || value === "merged" || value === "approved" || value === "ready_to_merge") return "ok"; + return ""; +} + // ── Debounce render to prevent flicker ── var renderTimer = null; function scheduleRender() { @@ -661,6 +799,7 @@ function enterFocusMode(workspace, agent) { // Hide factory, show focus document.getElementById("factory").style.display = "none"; + if ($opsConsole) $opsConsole.style.display = "none"; document.getElementById("detail-drawer").style.display = "none"; document.getElementById("dispatch-panel").style.display = "none"; document.getElementById("timeline").style.display = "none"; @@ -698,6 +837,7 @@ function exitFocusMode() { $focusView.style.display = "none"; document.getElementById("factory").style.display = ""; + if ($opsConsole) $opsConsole.style.display = ""; document.getElementById("dispatch-panel").style.display = ""; document.getElementById("timeline").style.display = ""; @@ -1025,9 +1165,10 @@ function reconstructRuns() { // ── Boot ── fetchHealth().then(function() { - return reconstructRuns(); + return Promise.all([reconstructRuns(), fetchOps()]); }).then(function() { connectSSE(); // Re-fetch health periodically to stay in sync setInterval(fetchHealth, 15000); + setInterval(fetchOps, 15000); }); diff --git a/dashboard/index.html b/dashboard/index.html index e419dfe..e650428 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -27,6 +27,20 @@ +
+
+

operator console

+
+
+
+
+
+

ops events

+
    +
    +
    +
    +
    diff --git a/dashboard/style.css b/dashboard/style.css index 31aaaf9..9a29f82 100644 --- a/dashboard/style.css +++ b/dashboard/style.css @@ -80,7 +80,7 @@ header#top-bar { font-family: var(--font-display); font-weight: 400; font-size: 1.25rem; - letter-spacing: -0.02em; + letter-spacing: 0; color: var(--text); } .dim { color: var(--dim); font-family: var(--font-mono); font-size: 0.8125rem; } @@ -284,6 +284,193 @@ main#factory { } .stage.empty .state-chip { background: transparent; color: var(--dim); border: 1px solid var(--dim); } +/* ── operator console ── */ +#operator-console { + border-top: 1px solid var(--border); + background: #0d1424; + padding: 12px 20px 14px; +} +.ops-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 10px; +} +.ops-head h2, +.ops-events h3 { + font-family: var(--font-mono); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--dim); + font-weight: 600; +} +.ops-summary { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} +.ops-chip { + display: inline-flex; + align-items: baseline; + gap: 5px; + min-width: 72px; + justify-content: center; + border: 1px solid var(--border); + border-radius: 4px; + padding: 4px 8px; + font-family: var(--font-mono); + background: rgba(249,250,251,0.02); +} +.ops-chip.ok { border-color: rgba(34,197,94,0.35); color: var(--working); } +.ops-chip.warn { border-color: rgba(245,158,11,0.4); color: var(--auth-fail); } +.ops-chip.danger { border-color: rgba(239,68,68,0.45); color: var(--blocked); } +.ops-chip-value { + font-size: 0.875rem; + font-weight: 600; +} +.ops-chip-label { + font-size: 0.5625rem; + color: var(--dim); + text-transform: uppercase; +} +.ops-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 12px; +} +.ops-runs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + min-width: 0; +} +.ops-run { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + padding: 10px; + min-width: 0; +} +.ops-run.blocked { border-color: var(--blocked); box-shadow: 0 0 14px rgba(239,68,68,0.12); } +.ops-run.waiting { border-color: rgba(245,158,11,0.45); } +.ops-run.ready { border-color: rgba(34,197,94,0.45); } +.ops-run-top { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} +.ops-run-top h3 { + font-family: var(--font-body); + font-size: 0.875rem; + line-height: 1.3; + font-weight: 600; + color: var(--text); + overflow-wrap: anywhere; +} +.ops-stage { + font-family: var(--font-mono); + font-size: 0.5625rem; + text-transform: uppercase; + color: var(--accent); + border: 1px solid rgba(99,102,241,0.45); + border-radius: 4px; + padding: 2px 5px; + flex-shrink: 0; +} +.ops-run-meta { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 6px; + font-family: var(--font-mono); + font-size: 0.625rem; + color: var(--dim); +} +.ops-state-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 9px; +} +.ops-pill { + display: inline-flex; + gap: 5px; + align-items: baseline; + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 6px; + font-family: var(--font-mono); + font-size: 0.625rem; + color: var(--text-secondary); +} +.ops-pill.ok { border-color: rgba(34,197,94,0.35); color: var(--working); } +.ops-pill.warn { border-color: rgba(245,158,11,0.4); color: var(--auth-fail); } +.ops-pill.danger { border-color: rgba(239,68,68,0.45); color: var(--blocked); } +.ops-pill-label { + color: var(--dim); + text-transform: uppercase; + font-size: 0.5rem; +} +.ops-artifact, +.ops-blocker, +.ops-next { + margin-top: 8px; + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.4; + overflow-wrap: anywhere; +} +.ops-artifact { color: var(--text-secondary); } +.ops-artifact-label, +.ops-blocker-label { + color: var(--dim); + text-transform: uppercase; + font-size: 0.5625rem; +} +.ops-blocker { + color: var(--blocked); + border-left: 2px solid var(--blocked); + padding-left: 8px; +} +.ops-next { + color: var(--text); + border-top: 1px solid var(--border); + padding-top: 8px; +} +.ops-empty { + color: var(--dim); + font-family: var(--font-mono); + font-size: 0.75rem; + padding: 10px 0; +} +.ops-events { + min-width: 0; + border-left: 1px solid var(--border); + padding-left: 12px; +} +.ops-events ul { + list-style: none; + margin-top: 6px; + font-family: var(--font-mono); + font-size: 0.6875rem; +} +.ops-events li { + display: flex; + gap: 6px; + align-items: baseline; + padding: 3px 0; + color: var(--dim); + border-bottom: 1px solid var(--border); +} +.ops-events li:last-child { border-bottom: none; } +.ops-events .evt-type { color: var(--text-secondary); font-weight: 500; min-width: 0; overflow-wrap: anywhere; } +.ops-events .evt-agent { color: var(--working); } +.ops-events .evt-time { margin-left: auto; font-size: 0.5625rem; flex-shrink: 0; } + /* ── detail drawer ── */ #detail-drawer { background: var(--surface); @@ -691,6 +878,21 @@ footer#timeline h3 { header#top-bar { flex-wrap: wrap; gap: 8px; padding: 12px 16px; } .bar-right { gap: 12px; } main#factory { padding: 16px 12px; } + #operator-console { padding: 12px; } + .ops-head { + align-items: flex-start; + flex-direction: column; + } + .ops-summary { justify-content: flex-start; width: 100%; } + .ops-chip { min-width: calc(50% - 4px); } + .ops-grid { grid-template-columns: 1fr; } + .ops-runs { grid-template-columns: 1fr; } + .ops-events { + border-left: none; + border-top: 1px solid var(--border); + padding-left: 0; + padding-top: 10px; + } .pipeline { flex-direction: column; align-items: stretch; diff --git a/docs/OPERATOR_DEBUGGING.md b/docs/OPERATOR_DEBUGGING.md index 202aee5..4353e2e 100644 --- a/docs/OPERATOR_DEBUGGING.md +++ b/docs/OPERATOR_DEBUGGING.md @@ -14,6 +14,8 @@ tt runs tt runs tt attach tt logs ## Step 1: Find the Failed Run +If `tt serve` is running, open the dashboard first. The operator console summarizes active runs, review state, merge-gate state, blockers, captured artifacts, and the next required action from `/v1/ops`. + ```bash tt runs ``` diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 20463dd..ddedae1 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -551,6 +551,7 @@ fn route_read( "/v1/status" | "/v1/voices" => Ok(api_ok("status.list", status_data(targets)?)), "/v1/workflows" => Ok(api_ok("workflows.list", workflows_data(targets))), "/v1/runs" => Ok(api_ok("runs.list", runs_data(targets)?)), + "/v1/ops" => Ok(api_ok("ops.summary", ops_data(targets)?)), "/v1/logs" => Ok(api_ok("logs.list", logs_data(targets)?)), "/v1/handoffs" => Ok(api_ok("handoffs.list", handoffs_data(targets)?)), "/v1/policy-decisions" => Ok(api_ok( @@ -1266,6 +1267,298 @@ fn runs_data(targets: &[WorkspaceTarget]) -> Result { Ok(Value::Array(rows)) } +/// Build a read-only operator-console summary from the SDLC ledger, step +/// intents, and recent control events. +fn ops_data(targets: &[WorkspaceTarget]) -> Result { + let mut runs = Vec::new(); + let mut recent_events = Vec::new(); + + for target in targets { + let active_runs = state::load_active_runs(&target.project_root)?; + for ledger in active_runs { + let steps = state::load_run_steps(&target.project_root, &ledger.run_id)?; + runs.push(ops_run_row(target, &ledger, &steps)); + } + + let mut events = state::load_control_events(&target.project_root)?; + events.sort_by_key(|event| std::cmp::Reverse(event.timestamp)); + for event in events.into_iter().take(20) { + recent_events.push(json!({ + "workspace": target.name, + "event": event.event, + "agent": event.agent, + "timestamp": event.timestamp, + "correlation_id": event.correlation_id, + })); + } + } + + runs.sort_by(|a, b| { + let a_ts = a + .get("updated_at") + .and_then(Value::as_str) + .unwrap_or_default(); + let b_ts = b + .get("updated_at") + .and_then(Value::as_str) + .unwrap_or_default(); + b_ts.cmp(a_ts) + }); + recent_events.sort_by(|a, b| { + let a_ts = a + .get("timestamp") + .and_then(Value::as_str) + .unwrap_or_default(); + let b_ts = b + .get("timestamp") + .and_then(Value::as_str) + .unwrap_or_default(); + b_ts.cmp(a_ts) + }); + recent_events.truncate(20); + + let blocked = runs + .iter() + .filter(|run| run.get("blocker").is_some_and(|v| !v.is_null())) + .count(); + let review_waiting = runs + .iter() + .filter(|run| { + run.pointer("/review/state") + .and_then(Value::as_str) + .is_some_and(|state| matches!(state, "waiting" | "blocked")) + }) + .count(); + let gate_ready = runs + .iter() + .filter(|run| { + run.pointer("/gate/state") + .and_then(Value::as_str) + .is_some_and(|state| state == "ready") + }) + .count(); + + Ok(json!({ + "summary": { + "active_runs": runs.len(), + "blocked_runs": blocked, + "review_waiting": review_waiting, + "gate_ready": gate_ready, + }, + "runs": runs, + "recent_events": recent_events, + })) +} + +fn ops_run_row( + target: &WorkspaceTarget, + ledger: &state::SdlcRunLedgerRecord, + steps: &[state::WorkflowStepIntentRecord], +) -> Value { + let blocker = ops_blocker(ledger, steps); + json!({ + "workspace": target.name, + "run_id": ledger.run_id, + "issue": { + "number": ledger.issue_number, + "title": ledger.issue_title, + "repository": ledger.repository, + }, + "workflow": ledger.workflow_name, + "state": format_sdlc_state(&ledger.state), + "stage": ops_stage(&ledger.state), + "updated_at": ledger.updated_at, + "branch": ledger.branch, + "current_step": ledger.current_step_id, + "last_successful_step": ledger.last_successful_step_id, + "active_agents": ledger.active_agents, + "artifact": ops_latest_artifact(steps), + "review": ops_review_state(&ledger.state, steps), + "gate": ops_gate_state(&ledger.state, steps), + "blocker": blocker, + "resume_eligible": ledger.resume_eligible, + "next_action": ops_next_action(ledger, steps), + }) +} + +fn format_sdlc_state(state: &state::SdlcRunState) -> &'static str { + match state { + state::SdlcRunState::Selected => "selected", + state::SdlcRunState::Branched => "branched", + state::SdlcRunState::Implemented => "implemented", + state::SdlcRunState::Tested => "tested", + state::SdlcRunState::Docs => "docs", + state::SdlcRunState::PrOpen => "pr_open", + state::SdlcRunState::Reviewed => "reviewed", + state::SdlcRunState::ReadyToMerge => "ready_to_merge", + state::SdlcRunState::Merged => "merged", + } +} + +fn ops_stage(state: &state::SdlcRunState) -> &'static str { + match state { + state::SdlcRunState::Selected | state::SdlcRunState::Branched => "intake", + state::SdlcRunState::Implemented + | state::SdlcRunState::Tested + | state::SdlcRunState::Docs => "execution", + state::SdlcRunState::PrOpen | state::SdlcRunState::Reviewed => "review", + state::SdlcRunState::ReadyToMerge => "gate", + state::SdlcRunState::Merged => "record", + } +} + +fn ops_review_state( + state: &state::SdlcRunState, + steps: &[state::WorkflowStepIntentRecord], +) -> Value { + let failed = steps.iter().any(|step| { + step.step_type == "review" && step.outcome.as_ref().is_some_and(|o| !o.success) + }); + let pending = steps + .iter() + .any(|step| step.step_type == "review" && step.outcome.is_none()); + let complete = steps + .iter() + .any(|step| step.step_type == "review" && step.outcome.as_ref().is_some_and(|o| o.success)); + + let review_state = if failed { + "blocked" + } else if complete + || matches!( + state, + state::SdlcRunState::Reviewed + | state::SdlcRunState::ReadyToMerge + | state::SdlcRunState::Merged + ) + { + "approved" + } else if pending || matches!(state, state::SdlcRunState::PrOpen) { + "waiting" + } else { + "not_started" + }; + + json!({ + "state": review_state, + "adapter": "review_gate", + }) +} + +fn ops_gate_state(state: &state::SdlcRunState, steps: &[state::WorkflowStepIntentRecord]) -> Value { + let failed = steps + .iter() + .any(|step| step.step_type == "land" && step.outcome.as_ref().is_some_and(|o| !o.success)); + let pending = steps + .iter() + .any(|step| step.step_type == "land" && step.outcome.is_none()); + let complete = steps + .iter() + .any(|step| step.step_type == "land" && step.outcome.as_ref().is_some_and(|o| o.success)); + + let gate_state = if failed { + "blocked" + } else if complete || matches!(state, state::SdlcRunState::Merged) { + "merged" + } else if matches!(state, state::SdlcRunState::ReadyToMerge) { + "ready" + } else if pending || matches!(state, state::SdlcRunState::Reviewed) { + "checking" + } else { + "not_started" + }; + + json!({ + "state": gate_state, + "checks": if failed { "failed" } else { gate_state }, + }) +} + +fn ops_blocker( + ledger: &state::SdlcRunLedgerRecord, + steps: &[state::WorkflowStepIntentRecord], +) -> Option { + if let Some(step) = steps.iter().find(|step| { + step.outcome + .as_ref() + .is_some_and(|outcome| !outcome.success) + }) { + return Some(json!({ + "category": ledger.failure_class.as_deref().unwrap_or(step.step_type.as_str()), + "step": step.step_id, + "message": step + .outcome + .as_ref() + .and_then(|outcome| outcome.message.clone()) + .or_else(|| ledger.failure_message.clone()) + .unwrap_or_else(|| "step failed".to_string()), + })); + } + + ledger.failure_class.as_ref().map(|category| { + json!({ + "category": category, + "step": ledger.current_step_id, + "message": ledger.failure_message, + }) + }) +} + +fn ops_latest_artifact(steps: &[state::WorkflowStepIntentRecord]) -> Option { + steps.iter().rev().find_map(|step| { + step.intent + .get("artifact_name") + .and_then(Value::as_str) + .map(|name| { + json!({ + "name": name, + "step": step.step_id, + "status": step + .outcome + .as_ref() + .map(|outcome| if outcome.success { "captured" } else { "failed" }) + .unwrap_or("pending"), + }) + }) + }) +} + +fn ops_next_action( + ledger: &state::SdlcRunLedgerRecord, + steps: &[state::WorkflowStepIntentRecord], +) -> String { + if let Some(step) = steps.iter().find(|step| { + step.outcome + .as_ref() + .is_some_and(|outcome| !outcome.success) + }) { + if let Some(agent_name) = step + .intent + .get("agent") + .or_else(|| step.intent.get("agent_scope")) + .and_then(Value::as_str) + { + return format!("Inspect {agent_name} for failed step {}", step.step_id); + } + return format!("Inspect failed step {}", step.step_id); + } + + if ledger.resume_eligible { + return format!("Run tt run --resume {}", ledger.run_id); + } + + if steps.iter().any(|step| step.outcome.is_none()) { + return "Wait for pending workflow steps".to_string(); + } + + match ledger.state { + state::SdlcRunState::PrOpen => "Trigger or inspect the review adapter".to_string(), + state::SdlcRunState::Reviewed => "Run the merge gate".to_string(), + state::SdlcRunState::ReadyToMerge => "Land or merge the approved PR".to_string(), + state::SdlcRunState::Merged => "No action required".to_string(), + _ => "Continue the workflow".to_string(), + } +} + /// List log files with metadata from all served workspaces fn logs_data(targets: &[WorkspaceTarget]) -> Result { let mut rows = Vec::new(); @@ -1964,4 +2257,79 @@ auth = "bearer" crate::config::ServeAuthMode::None ); } + + #[test] + fn ops_next_action_prioritizes_failed_step() { + let ledger = state::SdlcRunLedgerRecord { + run_id: "run-ops-1".to_string(), + issue_number: 42, + issue_title: Some("Fix review loop".to_string()), + repository: "nutthouse/tutti".to_string(), + workflow_name: "sdlc".to_string(), + state: state::SdlcRunState::PrOpen, + updated_at: Utc::now(), + actor: "tester".to_string(), + branch: Some("issue-42".to_string()), + failure_message: None, + failure_class: None, + current_step_id: Some("review-3".to_string()), + last_successful_step_id: Some("test-2".to_string()), + resume_eligible: false, + active_agents: vec!["reviewer".to_string()], + transitions: vec![], + }; + let steps = vec![state::WorkflowStepIntentRecord { + run_id: "run-ops-1".to_string(), + workflow_name: "sdlc".to_string(), + step_index: 3, + step_id: "review-3".to_string(), + step_type: "review".to_string(), + planned_at: Utc::now(), + intent: json!({"agent": "implementer"}), + attempt: 1, + outcome: Some(state::WorkflowStepOutcomeRecord { + completed_at: Utc::now(), + status: "failed".to_string(), + success: false, + exit_code: Some(1), + timed_out: false, + message: Some("review feedback unresolved".to_string()), + side_effects: None, + }), + }]; + + assert_eq!( + ops_next_action(&ledger, &steps), + "Inspect implementer for failed step review-3" + ); + assert_eq!( + ops_review_state(&ledger.state, &steps) + .pointer("/state") + .and_then(Value::as_str), + Some("blocked") + ); + } + + #[test] + fn ops_gate_state_marks_ready_to_merge() { + let steps = vec![state::WorkflowStepIntentRecord { + run_id: "run-ops-2".to_string(), + workflow_name: "sdlc".to_string(), + step_index: 4, + step_id: "land-4".to_string(), + step_type: "land".to_string(), + planned_at: Utc::now(), + intent: json!({"agent": "implementer", "pr": true}), + attempt: 1, + outcome: None, + }]; + + assert_eq!( + ops_gate_state(&state::SdlcRunState::ReadyToMerge, &steps) + .pointer("/state") + .and_then(Value::as_str), + Some("ready") + ); + assert_eq!(ops_stage(&state::SdlcRunState::ReadyToMerge), "gate"); + } } diff --git a/src/health/mod.rs b/src/health/mod.rs index 4052139..0076e48 100644 --- a/src/health/mod.rs +++ b/src/health/mod.rs @@ -255,6 +255,7 @@ pub fn recovery_trigger(health: &AgentHealth) -> Option { /// Minimum consecutive Working polls (without hash change) required to count as activity. /// Prevents flicker between Working/Unknown from falsely setting `saw_activity`. const WORKING_STATUS_CONSECUTIVE_THRESHOLD: u32 = 2; +const _: () = assert!(WORKING_STATUS_CONSECUTIVE_THRESHOLD >= 2); fn completion_signal_can_finish_wait( saw_activity: bool, @@ -629,12 +630,6 @@ mod tests { ); } - #[test] - fn working_consecutive_threshold_is_at_least_two() { - // The threshold prevents a single Working flicker from falsely setting saw_activity. - assert!(WORKING_STATUS_CONSECUTIVE_THRESHOLD >= 2); - } - #[test] fn completion_signal_is_ignored_while_runtime_is_still_working() { assert!(!completion_signal_can_finish_wait(