Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeEvent>` that maps a Slack event
Expand Down Expand Up @@ -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 |
Expand Down
34 changes: 34 additions & 0 deletions crates/crustcore-dev/src/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions crates/crustcore-dev/src/route_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions crates/crustcore-dev/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fn router<B: DevBackend + Send + 'static>(state: Arc<DevState<B>>) -> Router {
.route("/flow", get(dispatch::<B>))
.route("/sessions", get(dispatch::<B>))
.route("/approvals", get(dispatch::<B>))
.route("/cockpit", get(dispatch::<B>))
.route("/approvals/resolve", post(dispatch::<B>))
.with_state(state)
}
Expand Down
204 changes: 204 additions & 0 deletions crates/crustcore-dev/src/views/cockpit.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<String>,
/// 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<T>`.
#[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<TaskDetailView>,
/// The (bounded) pending approval forms.
pub approvals: Vec<ApprovalFormView>,
}

/// 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<TaskRow> = (0..(MAX_COCKPIT_TASKS as u128 + 50))
.map(|i| row(i, 1, None))
.collect();
let grid: Vec<TaskDetailView> = rows
.iter()
.take(MAX_COCKPIT_TASKS)
.map(TaskDetailView::from_row)
.collect();
assert_eq!(grid.len(), MAX_COCKPIT_TASKS);
}
}
4 changes: 4 additions & 0 deletions crates/crustcore-dev/src/views/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 7 additions & 6 deletions crates/crustcore-dev/tests/redteam_devui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
}

// ---------------------------------------------------------------------------
Expand Down
Loading