From 6126c59067dc56d4b6629d556ef5c75de58b9820 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 11:46:32 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(dev):=20cockpit=20view=20core=20(roadm?= =?UTF-8?q?ap-v0.6=20E.1)=20=E2=80=94=20completes=20Phase=20E=20+=20all=20?= =?UTF-8?q?of=20v0.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 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 (from the run inspector), EvidenceSummaryView (references only — frame counts/seq range/ terminal; never raw CoT or secrets, invariant 2), and ApprovalFormView carrying the operation-bound op-hash so a resolution can only approve the exact operation shown (invariant 14; binding checked by the existing dispatch_resolution). Every list bounded (MAX_COCKPIT_TASKS/APPROVALS, invariant 11); the cockpit renders evidence but mints nothing (invariant 13), supervisor-only (invariant 5). Pure view core over MockDevBackend; the axum bind + /ws tick loop + HTML/JS assets remain the existing C7-serve-live seam. Completes Phase E and the entire roadmap-v0.6 buildable scope. 4 new tests; non-nano; zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 15 ++ crates/crustcore-dev/src/views/cockpit.rs | 204 ++++++++++++++++++++++ crates/crustcore-dev/src/views/mod.rs | 4 + 3 files changed, 223 insertions(+) create mode 100644 crates/crustcore-dev/src/views/cockpit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7fdf1..62ac0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ 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). Pure view core over `MockDevBackend`; the axum bind + `/ws` + tick loop + HTML/JS assets 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**. + - **Evidence bundle rendering (roadmap-v0.6 C.3).** Added `EvidenceBundle::to_markdown()` and `to_json()` to `crustcore_daemon::product`. `to_markdown` is the **bounded** canonical PR-body/cockpit renderer: it opens with @@ -249,6 +263,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/C.3 | `EvidenceBundle::to_markdown` (bounded PR-body/cockpit render, 🔴 review notice, per-list overflow) + `to_json` (schema v1); `draft_pr_body` delegates | `claude/v06-c3-evidence` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 2, 10, 11; bounded redacted evidence, every receipt included | | 2026-06-28 | v0.6/D.1 | Task-loop wiring `plan_task`/`finalize_task` composing routing (C.1) + advisory gate (C.2) into a terminal `TaskOutcome`; sandboxed run `#[ignore]`d | `claude/v06-d1-executor-wire` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 4, 5, 6, 13; verifier-owned completion, advisory only gates | | 2026-06-28 | v0.6/A.3 | `pr_intent_to_create_request`: PrIntent→CreatePrRequest for the live draft-PR POST; evidence body verbatim, draft=true; real POST `#[ignore]`d | `claude/v06-a3-draftpr` | Claude (Implementer) | 0 kB (daemon/live-only) | Enforces 6, 13, 14; body is evidence not a model claim | 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; From 6b54a4adbfb7ac1124eddd24a6d065f47de7e2ef Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 13:29:31 +0200 Subject: [PATCH 2/2] feat(dev): wire the E.1 cockpit serve route (read-only GET /cockpit) Wires build_cockpit into the dev-UI serve layer: a read-only GET /cockpit route (route_class table + handle_read render via format!("{:?}", ...), the same pure mutation::route core the other views use) + the axum .route behind the serve feature. CI-tested over MockDevBackend (renders a bounded frame; a POST is rejected as not-the-one-mutating-route). Updated the route-table red-team test (11 read + 1 mutating; the cockpit adds a GET, never a second mutating route). Now only the axum bind + /ws tick loop remain the existing C7-serve-live seam. 2 new tests; crustcore-dev (non-nano); zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++-- crates/crustcore-dev/src/mutation.rs | 34 +++++++++++++++++++++ crates/crustcore-dev/src/route_class.rs | 2 ++ crates/crustcore-dev/src/serve.rs | 1 + crates/crustcore-dev/tests/redteam_devui.rs | 13 ++++---- 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ac0fd..b1d64de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,8 +39,10 @@ agent/PR/role/size/invariant audit trail. 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). Pure view core over `MockDevBackend`; the axum bind + `/ws` - tick loop + HTML/JS assets remain the existing `C7-serve-live` seam. **This completes + 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**. 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/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); } // ---------------------------------------------------------------------------