diff --git a/CHANGELOG.md b/CHANGELOG.md index b06a737..02295e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,22 @@ agent/PR/role/size/invariant audit trail. ### Added +- **Cockpit view core (roadmap-v0.6 E.1).** Added `crustcore_dev::views::cockpit`: + `build_cockpit(backend) → CockpitView` composes the existing redacted, read-only + event-log read-model into a bounded task/evidence/approval frame — `TaskDetailView` + (rolled up from the run inspector), `EvidenceSummaryView` (**references only** — frame + counts, seq range, terminal kind; never raw chain-of-thought or secrets, invariant 2), + and `ApprovalFormView` carrying the **operation-bound op-hash** so a resolution can only + approve the exact operation shown (invariant 14 — the binding is checked by the existing + `dispatch_resolution`). Every list is bounded (`MAX_COCKPIT_TASKS`/`MAX_COCKPIT_APPROVALS`, + invariant 11); the cockpit **renders evidence but mints nothing** (invariant 13) and is + supervisor-only (invariant 5). **The serve route is wired**: a read-only `GET /cockpit` + route (route table + `handle_read` render via the pure `mutation::route` core, CI-tested + over `MockDevBackend` + a POST-rejected red-team check) renders the frame; only the axum + bind + `/ws` tick loop remain the existing `C7-serve-live` seam. **This completes + Phase E and the entire roadmap-v0.6 buildable scope.** 4 new tests; non-nano; **zero nano + impact**. + - **Slack runtime control plane (roadmap-v0.6 E.3).** Added `crustcore_daemon::slack`: a pure `SlackAllowlist` (per-workspace/per-channel, **deny-all empty** — invariants 5, 15), `normalize_message(msg, allowlist) → Option` that maps a Slack event @@ -373,6 +389,7 @@ agent/PR/role/size/invariant audit trail. | Date | Phase/Task | Change | PR / Branch | Agent / Role | Nano Δ | Invariants | | --- | --- | --- | --- | --- | --- | --- | +| 2026-06-28 | v0.6/E.1 | Cockpit view core: `build_cockpit` composes TaskDetail/EvidenceSummary(refs-only)/ApprovalForm(op-hash-bound) from the read-model, bounded; renders evidence, mints nothing. Completes Phase E + all of v0.6 | `claude/v06-e1-cockpit` | Claude (Implementer) | 0 kB (crustcore-dev) | Enforces 2, 5, 11, 13, 14; read-model only, op-bound approvals, no minting | | 2026-06-28 | v0.6/E.3 | `slack::SlackAllowlist` + `normalize_message` mirroring Telegram (same RuntimeEvent stream + gates, deny-all empty) + redacted `render_to_slack`; live API `#[ignore]`d | `claude/v06-e3-slack` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 1-5, 7, 8, 11, 15, 16; opt-in, redacted, same dispatch as Telegram | | 2026-06-28 | v0.6/F.1 | `snapshot_all`/`adopt_from_snapshot` cross-process recovery: stable ids, re-leased under new owner, Pending-resume-from-log, carried-usage re-charge, over-budget→terminal. Completes Phase F | `claude/v06-f1-recovery` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 11, 12, 13; recovery restores supervision, never completion | | 2026-06-28 | v0.6/F.3 | `multirepo::classify_repo` (explicit-hint → sole-repo default → ambiguous asks) + `RepoBinding`; intent matches operator keywords only, never supplies a path | `claude/v06-f3-multirepo` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 7, 11; repo paths from config/CLI, shared global cap | diff --git a/crates/crustcore-dev/src/mutation.rs b/crates/crustcore-dev/src/mutation.rs index 53fa7c3..76d7763 100644 --- a/crates/crustcore-dev/src/mutation.rs +++ b/crates/crustcore-dev/src/mutation.rs @@ -201,6 +201,7 @@ fn handle_read(backend: &dyn ReadOnlyBackend, req: &DevRequest) -> DevResponse { "/flow" => format!("{:?}", backend.flow_graph()), "/sessions" => format!("{:?}", backend.sessions()), "/approvals" => format!("{:?}", crate::views::approvals::render(backend)), + "/cockpit" => format!("{:?}", crate::views::cockpit::build_cockpit(backend)), _ => return DevResponse::error(Status::NotFound, "no such read view"), }; DevResponse::ok(body) @@ -293,6 +294,39 @@ mod tests { assert!(resp.body.contains("CrustCore dev UI styles")); } + #[test] + fn cockpit_route_renders_a_bounded_read_only_frame() { + // E.1 serve route: an authed GET /cockpit is classified ReadOnly and renders the + // cockpit frame from the read-model — no mutation, no minting. + let config = DevConfig::default(); // mutation OFF by default + let auth = Authenticator::new(token()); + let mut mock = MockDevBackend::new(); + let resp = route( + &mut mock, + &auth, + &config, + &authed("GET", "/cockpit", Vec::new()), + ); + assert_eq!(resp.status, Status::Ok); + assert!(resp.body.contains("CockpitView"), "got: {}", resp.body); + assert!(resp.body.contains("chain_intact")); + } + + #[test] + fn cockpit_route_rejects_a_post_as_not_a_mutating_route() { + // The cockpit is read-only: a POST to it is not the one mutating route. + let config = DevConfig::default(); + let auth = Authenticator::new(token()); + let mut mock = MockDevBackend::new(); + let resp = route( + &mut mock, + &auth, + &config, + &authed("POST", "/cockpit", Vec::new()), + ); + assert_eq!(resp.status, Status::NotFound); + } + #[test] fn ws_is_a_documented_non_streaming_read_route() { let config = DevConfig::default(); diff --git a/crates/crustcore-dev/src/route_class.rs b/crates/crustcore-dev/src/route_class.rs index 3f6ea08..5826e17 100644 --- a/crates/crustcore-dev/src/route_class.rs +++ b/crates/crustcore-dev/src/route_class.rs @@ -81,6 +81,8 @@ pub const ROUTES: &[RouteSpec] = &[ RouteSpec::read("/flow"), RouteSpec::read("/sessions"), RouteSpec::read("/approvals"), + // The cockpit frame (roadmap-v0.6 E.1): task grid + evidence + approval forms. + RouteSpec::read("/cockpit"), // The single mutating route: dispatch an approval resolution into the approval // engine. Off unless the launch flag enables it AND a valid op-bound token arrives. RouteSpec::mutating("/approvals/resolve"), diff --git a/crates/crustcore-dev/src/serve.rs b/crates/crustcore-dev/src/serve.rs index 7322736..5ea6314 100644 --- a/crates/crustcore-dev/src/serve.rs +++ b/crates/crustcore-dev/src/serve.rs @@ -82,6 +82,7 @@ fn router(state: Arc>) -> Router { .route("/flow", get(dispatch::)) .route("/sessions", get(dispatch::)) .route("/approvals", get(dispatch::)) + .route("/cockpit", get(dispatch::)) .route("/approvals/resolve", post(dispatch::)) .with_state(state) } diff --git a/crates/crustcore-dev/src/views/cockpit.rs b/crates/crustcore-dev/src/views/cockpit.rs new file mode 100644 index 0000000..e476919 --- /dev/null +++ b/crates/crustcore-dev/src/views/cockpit.rs @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Cockpit view (roadmap-v0.6 E.1). +//! +//! A task/evidence/approval **cockpit** built entirely from the existing redacted, +//! read-only event-log read-model ([`ReadOnlyBackend`]). It renders evidence and surfaces +//! approval forms; it **cannot approve, complete, or integrate** — every value comes from +//! an already-redacted source (invariant 2), the lists are bounded (invariant 11), it +//! renders evidence but never mints a `VerifiedPatch` (invariant 13), and an approval form +//! carries the **operation-bound op-hash** so a resolution can only approve the exact +//! operation shown (invariant 14; the binding is checked by +//! [`MutatingBackend::dispatch_resolution`](crate::backend::MutatingBackend)). +//! +//! This is the **pure view core** over [`MockDevBackend`](crate::backend::MockDevBackend); +//! the axum bind, the `/ws` tick loop, and the HTML/JS assets are the `C7-serve-live` seam. + +use crate::backend::{ApprovalView, ReadOnlyBackend, TaskRow}; +use crustcore_eventlog::ChainStatus; + +/// Max tasks rendered in one cockpit frame (bounded — invariant 11; mirrors the snapshot +/// task cap so a huge log can't make an unbounded page). +pub const MAX_COCKPIT_TASKS: usize = 256; +/// Max approval forms surfaced in one frame (bounded). +pub const MAX_COCKPIT_APPROVALS: usize = 64; + +/// A task's evidence summary for the cockpit: **references only** (frame counts, sequence +/// range, terminal kind) — never raw chain-of-thought or secret-bearing content. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvidenceSummaryView { + /// Bounded evidence references (each a short, non-sensitive label). + pub refs: Vec, +} + +/// One task's detail in the cockpit, rolled up from the run inspector's [`TaskRow`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskDetailView { + /// The task id. + pub task_id: u128, + /// Verified frames seen for this task. + pub frames: u64, + /// First sequence number. + pub first_seq: u64, + /// Last sequence number. + pub last_seq: u64, + /// The terminal event kind, if the task ended. + pub terminal: Option, + /// Reference-only evidence summary. + pub evidence: EvidenceSummaryView, +} + +impl TaskDetailView { + /// Builds a detail view from a run-inspector row. Evidence is **refs only**. + #[must_use] + pub fn from_row(row: &TaskRow) -> Self { + let mut refs = vec![ + format!("frames: {}", row.frames), + format!("seq: {}..{}", row.first_seq.0, row.last_seq.0), + ]; + if let Some(t) = &row.terminal { + refs.push(format!("terminal: {t}")); + } + TaskDetailView { + task_id: row.task_id.0, + frames: row.frames, + first_seq: row.first_seq.0, + last_seq: row.last_seq.0, + terminal: row.terminal.clone(), + evidence: EvidenceSummaryView { refs }, + } + } +} + +/// An approval form the cockpit surfaces. The `op_hash_hex` is the **binding**: the +/// resolution the form submits carries it, and `dispatch_resolution` rejects a mismatch — +/// so resolving the form for approval A can never approve a different operation B +/// (invariant 14). The cockpit mints nothing; only the engine mints `Approved`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalFormView { + /// The approval id the form resolves. + pub approval_id: u128, + /// The operation-bound hash the resolution must echo (hex). + pub op_hash_hex: String, + /// A redacted, bounded human summary of the operation. + pub summary: String, + /// When the approval expires (millis). + pub expires_at_millis: u64, +} + +impl ApprovalFormView { + /// Builds a form from a pending-approval view, carrying the op-hash binding. + #[must_use] + pub fn from_view(a: &ApprovalView) -> Self { + ApprovalFormView { + approval_id: a.approval_id, + op_hash_hex: a.op_hash_hex.clone(), + summary: a.summary.clone(), + expires_at_millis: a.expires_at_millis, + } + } +} + +/// The full cockpit frame: the chain-health flag, the bounded task grid, and the pending +/// approval forms — all from the redacted read-model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CockpitView { + /// Whether the event-log hash chain verified intact. + pub chain_intact: bool, + /// The (bounded) task detail grid. + pub tasks: Vec, + /// The (bounded) pending approval forms. + pub approvals: Vec, +} + +/// Composes a cockpit frame from a [`ReadOnlyBackend`]. Pure — it reads the redacted view +/// model and bounds every list (invariant 11). It performs no mutation and mints nothing. +#[must_use] +pub fn build_cockpit(backend: &dyn ReadOnlyBackend) -> CockpitView { + let inspector = backend.run_inspector(); + let chain_intact = matches!(inspector.chain, ChainStatus::Intact { .. }); + let tasks = inspector + .tasks + .iter() + .take(MAX_COCKPIT_TASKS) + .map(TaskDetailView::from_row) + .collect(); + let approvals = backend + .pending_approvals() + .iter() + .take(MAX_COCKPIT_APPROVALS) + .map(ApprovalFormView::from_view) + .collect(); + CockpitView { + chain_intact, + tasks, + approvals, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::MockDevBackend; + use crustcore_types::{EventSeq, TaskId}; + + fn row(id: u128, frames: u64, terminal: Option<&str>) -> TaskRow { + TaskRow { + task_id: TaskId(id), + frames, + first_seq: EventSeq(1), + last_seq: EventSeq(frames), + terminal: terminal.map(str::to_string), + } + } + + #[test] + fn task_detail_carries_evidence_refs_only() { + let v = TaskDetailView::from_row(&row(7, 5, Some("TaskCompleted"))); + assert_eq!(v.task_id, 7); + // Evidence is references — frame count, seq range, terminal — never raw content. + assert!(v.evidence.refs.iter().any(|r| r.contains("frames: 5"))); + assert!(v.evidence.refs.iter().any(|r| r.contains("seq: 1..5"))); + assert!(v + .evidence + .refs + .iter() + .any(|r| r.contains("terminal: TaskCompleted"))); + } + + #[test] + fn approval_form_carries_the_op_hash_binding() { + let a = ApprovalView { + approval_id: 42, + op_hash_hex: "abcd1234".to_string(), + summary: "open a draft PR".to_string(), + expires_at_millis: 10_000, + }; + let form = ApprovalFormView::from_view(&a); + assert_eq!(form.approval_id, 42); + // The op-hash binds the resolution to this exact operation (invariant 14). + assert_eq!(form.op_hash_hex, "abcd1234"); + } + + #[test] + fn build_cockpit_over_the_mock_backend_composes_a_bounded_frame() { + // An empty mock yields an empty, well-formed frame (composition + bounds hold). + let mock = MockDevBackend::new(); + let cockpit = build_cockpit(&mock); + assert!(cockpit.tasks.len() <= MAX_COCKPIT_TASKS); + assert!(cockpit.approvals.len() <= MAX_COCKPIT_APPROVALS); + } + + #[test] + fn the_task_grid_is_bounded() { + // More rows than the cap → the grid is capped (no unbounded page; invariant 11). + let rows: Vec = (0..(MAX_COCKPIT_TASKS as u128 + 50)) + .map(|i| row(i, 1, None)) + .collect(); + let grid: Vec = rows + .iter() + .take(MAX_COCKPIT_TASKS) + .map(TaskDetailView::from_row) + .collect(); + assert_eq!(grid.len(), MAX_COCKPIT_TASKS); + } +} diff --git a/crates/crustcore-dev/src/views/mod.rs b/crates/crustcore-dev/src/views/mod.rs index 4682c29..0b5ec9f 100644 --- a/crates/crustcore-dev/src/views/mod.rs +++ b/crates/crustcore-dev/src/views/mod.rs @@ -4,6 +4,10 @@ //! frame, advances a budget, or reaches the verifier. pub mod approvals; +/// Cockpit view (roadmap-v0.6 E.1): composes the read-model into a bounded task/evidence/ +/// approval frame. Renders evidence + surfaces op-hash-bound approval forms — never +/// approves, completes, or integrates. +pub mod cockpit; pub mod flow; pub mod inspector; pub mod mcp; diff --git a/crates/crustcore-dev/tests/redteam_devui.rs b/crates/crustcore-dev/tests/redteam_devui.rs index 02f4a63..2470aaf 100644 --- a/crates/crustcore-dev/tests/redteam_devui.rs +++ b/crates/crustcore-dev/tests/redteam_devui.rs @@ -107,10 +107,11 @@ fn assets_serves_the_stylesheet_bytes() { } #[test] -fn route_table_is_unchanged_ten_read_and_one_mutating() { - // The SPA reuses the existing read routes; it adds none. The table stays exactly the - // ten read GET routes (`/`, `/assets`, `/ws`, `/inspector`, `/replay`, `/provider`, - // `/mcp`, `/flow`, `/sessions`, `/approvals`) plus the single mutating POST. +fn route_table_is_eleven_read_and_one_mutating() { + // The read GET routes (`/`, `/assets`, `/ws`, `/inspector`, `/replay`, `/provider`, + // `/mcp`, `/flow`, `/sessions`, `/approvals`, and the E.1 `/cockpit` frame) plus the + // single mutating POST (`/approvals/resolve`). The cockpit is **read-only** — it adds + // a GET, never a second mutating route, so the one-mutating-route invariant holds. let read = ROUTES .iter() .filter(|r| r.class == RouteClass::ReadOnly) @@ -119,9 +120,9 @@ fn route_table_is_unchanged_ten_read_and_one_mutating() { .iter() .filter(|r| r.class == RouteClass::Mutating) .count(); - assert_eq!(read, 10, "exactly ten read routes"); + assert_eq!(read, 11, "exactly eleven read routes (incl. /cockpit)"); assert_eq!(mutating, 1, "exactly one mutating route"); - assert_eq!(ROUTES.len(), 11); + assert_eq!(ROUTES.len(), 12); } // ---------------------------------------------------------------------------