mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-25 00:24:19 +00:00
feat(rvforge-reader): ADR-295 Agent Dock — typed trust boundary, states, roster
Trust boundary enforced structurally: AgentProvidedStatus (sanitized task text + progress only) composed separately from SystemOwnedStatus (state, trust badge, network, permissions, witness, cost) — agent input cannot reach system fields by construction. Sanitizer strips ANSI/ control chars, caps length, flags system-label mimicry as suspicious. 8-state machine (pause/terminate always one action; quarantine/ capability-denied not agent-exitable), attention-priority roster (approval > denial > error > running), D8 event thresholds, pill + expanded UI with visually distinct system chrome. 90 reader tests green. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01ParP55bZs2iTGEGvpnUecx
This commit is contained in:
parent
67a4ac241d
commit
75bb55b611
15 changed files with 3621 additions and 4 deletions
|
|
@ -13,12 +13,17 @@ src/inspect.rs read a package without executing it (STUB)
|
|||
src/capability.rs derive the P6 install-time capability contract
|
||||
src/runtime.rs apply the FR004 runtime selection order
|
||||
src/state.rs ADR-288 encrypted state-capsule layout (encryption STUB)
|
||||
src/dock.rs ADR-295 dock entry: the agent/system split, in the types
|
||||
src/dock_text.rs sanitizing and screening agent-authored strings
|
||||
src/dock_state.rs the eight dock states and who may move between them
|
||||
src/dock_roster.rs multi-agent policy and the two-interaction control path
|
||||
src/dock_events.rs D8 event thresholds — what may interrupt
|
||||
src/commands.rs Tauri command wrappers (feature `desktop`)
|
||||
src/lib.rs module root + the security invariants this crate holds
|
||||
ui/ three screens, plain HTML/CSS/JS, no build step
|
||||
ui/ four screens, plain HTML/CSS/JS, no build step
|
||||
assets/ vendored copy of compatibility-matrix.json
|
||||
capabilities/ Tauri v2 ACL: file dialog only
|
||||
tests/ runtime selection, capability card, inspection, state
|
||||
tests/ runtime selection, capability card, inspection, state, dock
|
||||
```
|
||||
|
||||
## Build and test
|
||||
|
|
@ -35,7 +40,7 @@ tests with only `serde` and `serde_json`:
|
|||
```bash
|
||||
cd crates/rvforge-reader
|
||||
cargo check # core only — no webview packages needed
|
||||
cargo test # 39 tests, no Tauri dependency
|
||||
cargo test # 90 tests, no Tauri dependency
|
||||
```
|
||||
|
||||
This is the CI-testable path. Building the desktop shell additionally needs the
|
||||
|
|
@ -64,7 +69,9 @@ there is no `.ico` or `.icns` yet, which Windows and macOS bundling require.
|
|||
| `inspect::capability_card` | Reads a `<file>.rvf.manifest.json` development sidecar if present; otherwise returns the everything-denied card. | The signed `CapabilityManifest` segment inside the RVF |
|
||||
| `state::seal` / `unseal` | Return `EncryptionNotImplemented`. | AEAD sealing bound to the base identity, with customer-held keys (ADR-288) |
|
||||
| Install / Customize permissions | Disabled buttons. | The install flow, once verification is real |
|
||||
| Emergency controls | Disabled buttons. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) |
|
||||
| Emergency controls | Disabled buttons on the Runtime screen. | `rvm-ffi` lifecycle calls (pause, terminate, revoke, rollback) |
|
||||
| Dock roster | In-process, seeded by `dock_add_scaffold_agent`, which reports the unknown value for every security-bearing field (unverified publisher, no confinement, no witness chain). | RVM telemetry over `rvm-ffi`; pause is `rvm_suspend`, terminate is `rvm_terminate` (ADR-295 implementation note) |
|
||||
| Dock instruction field | Disabled placeholder. | The agent instruction channel, once `rvm-ffi` is wired in |
|
||||
| Witness status | "no witness chain". | `rvm witness` export (ADR-289 §3) |
|
||||
|
||||
The stubs report *absence*, never a benign default. `VerificationStatus` and
|
||||
|
|
@ -98,6 +105,12 @@ replacement of every stub above.
|
|||
7. **Hosted mode does not claim bare-metal isolation.** The card shows the
|
||||
isolation class the matrix records for the selected profile —
|
||||
`os-sandbox+wasm`, never `partition` (ADR-285).
|
||||
8. **Agent input cannot reach dock chrome.** An agent supplies task text and
|
||||
progress, through `AgentProvidedStatus::from_agent(&str, i64)` and nowhere
|
||||
else. State, trust badge, network indicator, permission summary, witness
|
||||
status, resource usage, and cost live in `SystemOwnedStatus`, whose fields
|
||||
are private and whose single constructor takes no agent-derived value
|
||||
(ADR-295 §7).
|
||||
|
||||
Today `HostProfile::detect()` claims no OS confinement, no KVM, and no measured
|
||||
boot, because the `rvm-host` adapters do not exist yet. Selection therefore
|
||||
|
|
@ -114,6 +127,40 @@ as adapters land — not before.
|
|||
selection order and where it came from, a per-profile eligibility table, and
|
||||
the emergency controls (Pause · Terminate · Disconnect Network · Revoke
|
||||
Capabilities · Rollback State) as disabled placeholders.
|
||||
4. **Dock** — the ADR-295 agent dock. A collapsed pill (icon · name ·
|
||||
agent-reported task · progress · state · Pause · Stop · Expand), an expanded
|
||||
panel with the ten §2 elements and the seven-line §8 capability card, and the
|
||||
§5 multi-agent collapse: one active agent, two secondary icons, an overflow
|
||||
count, and aggregates computed over every agent including the hidden ones.
|
||||
|
||||
## The dock trust boundary (ADR-295 §7)
|
||||
|
||||
Agent-authored text is confined to `.agent-region` — an inset, dashed,
|
||||
monospace block on a tinted background, preceded by a dock-drawn
|
||||
"Agent-reported" label and clipped to one line. It reaches the DOM only through
|
||||
`textContent`, so it cannot produce markup, a class name, or a state indicator.
|
||||
System chrome uses presentation the agent has no way to emit from inside that
|
||||
region.
|
||||
|
||||
Before it is rendered, agent text is stripped of ANSI escapes, control
|
||||
characters, bidi overrides and line breaks, capped at 120 characters, and then
|
||||
screened for chrome mimicry: a task string reading "Paused", "Stopped", "✓
|
||||
Network disabled", or "Verified publisher" is withheld and flagged rather than
|
||||
drawn next to the dock's own labels. System-authored lines are cleaned but not
|
||||
screened, because "Running → Paused" written by the runtime *is* the label.
|
||||
|
||||
The state machine carries the same rule: an agent may assert `Idle`, `Running`,
|
||||
`WaitingForApproval`, `Error`, or `Completed`, and nothing else. It cannot put
|
||||
itself into `Paused`, `CapabilityDenied`, or `Quarantined`, and it cannot leave
|
||||
them. Pause and terminate are legal for the user from every non-terminal state,
|
||||
in one call, with no confirmation chain — which is what keeps the acceptance
|
||||
test ("identify, read, inspect permissions, terminate, in two interactions")
|
||||
satisfiable: expand carries the permissions and the capability card, terminate
|
||||
is reachable from the collapsed pill.
|
||||
|
||||
Event thresholds (§9) allow only approvals, policy violations, cost limits,
|
||||
failures, and milestones to interrupt. Progress, tool calls, network samples,
|
||||
and state changes accrue silently into the expanded panel's recent actions.
|
||||
|
||||
## Keeping the matrix in sync
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,16 @@
|
|||
//! webview. None of these commands executes package content.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::capability::CapabilityCard;
|
||||
use crate::dock::{
|
||||
AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage,
|
||||
SystemOwnedStatus, TrustBadge, WitnessStatus,
|
||||
};
|
||||
use crate::dock_events::Notification;
|
||||
use crate::dock_roster::{AgentPill, DockRoster, ExpandedAgentView, RosterView};
|
||||
use crate::dock_state::DockState;
|
||||
use crate::inspect::{self, InspectionSummary};
|
||||
use crate::runtime::{self, HostProfile, RuntimeChoice};
|
||||
|
||||
|
|
@ -32,3 +40,125 @@ pub fn runtime_selection(path: String) -> Result<RuntimeChoice, String> {
|
|||
choice.subject = Some(path);
|
||||
Ok(choice)
|
||||
}
|
||||
|
||||
/// The dock's roster, managed by the Tauri app.
|
||||
///
|
||||
/// Chrome state lives on the Rust side of the process boundary, which is what
|
||||
/// makes ADR-295 §7 structural here: the webview can read it and can invoke
|
||||
/// user controls, but the agent-facing command is
|
||||
/// [`dock_agent_report`] and it carries only task text and progress.
|
||||
#[derive(Default)]
|
||||
pub struct DockSurface(pub Mutex<DockRoster>);
|
||||
|
||||
impl DockSurface {
|
||||
fn with<R>(&self, f: impl FnOnce(&mut DockRoster) -> R) -> Result<R, String> {
|
||||
let mut roster = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "dock roster lock is poisoned".to_string())?;
|
||||
Ok(f(&mut roster))
|
||||
}
|
||||
}
|
||||
|
||||
/// The collapsed bar: active agent, two secondary slots, overflow count,
|
||||
/// aggregate usage, approval count.
|
||||
#[tauri::command]
|
||||
pub fn dock_view(dock: tauri::State<'_, DockSurface>) -> Result<RosterView, String> {
|
||||
dock.with(|r| r.view())
|
||||
}
|
||||
|
||||
/// Interaction one of the acceptance test.
|
||||
#[tauri::command]
|
||||
pub fn dock_expand(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
agent_id: String,
|
||||
) -> Result<ExpandedAgentView, String> {
|
||||
dock.with(|r| r.expand(&agent_id))?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// One action, from any non-terminal state.
|
||||
#[tauri::command]
|
||||
pub fn dock_pause(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
agent_id: String,
|
||||
) -> Result<DockState, String> {
|
||||
dock.with(|r| r.pause(&agent_id))?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Interaction two of the acceptance test. No confirmation chain.
|
||||
#[tauri::command]
|
||||
pub fn dock_terminate(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
agent_id: String,
|
||||
) -> Result<DockState, String> {
|
||||
dock.with(|r| r.terminate(&agent_id))?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Behind the overflow count.
|
||||
#[tauri::command]
|
||||
pub fn dock_swarm_view(dock: tauri::State<'_, DockSurface>) -> Result<Vec<AgentPill>, String> {
|
||||
dock.with(|r| r.swarm_view())
|
||||
}
|
||||
|
||||
/// Threshold events only (ADR-295 §9).
|
||||
#[tauri::command]
|
||||
pub fn dock_notifications(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
) -> Result<Vec<Notification>, String> {
|
||||
dock.with(|r| r.event_log().notifications().to_vec())
|
||||
}
|
||||
|
||||
/// **The agent channel.** The whole agent-supplied surface is these two
|
||||
/// arguments, and both are sanitized and clamped before they are stored. There
|
||||
/// is no command through which an agent sets its own state, trust badge,
|
||||
/// network indicator, permissions, witness status, cost, or resource usage.
|
||||
#[tauri::command]
|
||||
pub fn dock_agent_report(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
progress: i64,
|
||||
) -> Result<(), String> {
|
||||
dock.with(|r| r.apply_agent_update(&agent_id, &task, progress))?
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Register a placeholder agent so the dock can be exercised before `rvm-ffi`
|
||||
/// is wired in.
|
||||
///
|
||||
/// Every security-bearing field reports its unknown value — unverified
|
||||
/// publisher, no confinement engaged, no witness chain — because the scaffold
|
||||
/// has measured none of them. It does not fabricate a trusted-looking agent.
|
||||
#[tauri::command]
|
||||
pub fn dock_add_scaffold_agent(
|
||||
dock: tauri::State<'_, DockSurface>,
|
||||
agent_id: String,
|
||||
name: String,
|
||||
icon: String,
|
||||
) -> Result<RosterView, String> {
|
||||
let system = SystemOwnedStatus::measured(
|
||||
DockState::Idle,
|
||||
TrustBadge::Unverified,
|
||||
NetworkIndicator::Disabled,
|
||||
PermissionSummary {
|
||||
isolation: IsolationClass::None,
|
||||
local_model: false,
|
||||
network_allowed: false,
|
||||
selected_folder_access: false,
|
||||
encrypted_memory: false,
|
||||
grants: Vec::new(),
|
||||
pending_approvals: Vec::new(),
|
||||
},
|
||||
WitnessStatus::Unavailable,
|
||||
ResourceUsage::ZERO,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
dock.with(|r| {
|
||||
r.insert(AgentDockEntry::new(agent_id, name, icon, system));
|
||||
r.view()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
405
crates/rvforge-reader/src/dock.rs
Normal file
405
crates/rvforge-reader/src/dock.rs
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
//! Agent Dock domain model (ADR-295 §1, §2, §7, §8).
|
||||
//!
|
||||
//! The load-bearing rule of ADR-295 is §7: dock chrome is RVForge's, content is
|
||||
//! the agent's, and the two must not be confusable. That rule is enforced here
|
||||
//! **in the types**, not in the renderer:
|
||||
//!
|
||||
//! - [`AgentProvidedStatus`] carries the only two things an agent supplies —
|
||||
//! task text and progress — and it can only be built by
|
||||
//! [`AgentProvidedStatus::from_agent`], which sanitizes both.
|
||||
//! - [`SystemOwnedStatus`] carries everything with a security meaning (state,
|
||||
//! trust badge, network indicator, permission summary, witness status,
|
||||
//! resource usage, cost). Its fields are private, it derives no
|
||||
//! `Deserialize`, and it has exactly one constructor —
|
||||
//! [`SystemOwnedStatus::measured`] — which takes no agent-derived value.
|
||||
//! - [`AgentDockEntry`] composes the two. The only mutator that accepts agent
|
||||
//! input is [`AgentDockEntry::apply_agent_update`], and it replaces the agent
|
||||
//! half wholesale; there is no method, field, or trait impl through which
|
||||
//! agent bytes reach the system half.
|
||||
//!
|
||||
//! An agent that wants to appear paused therefore has to actually be paused,
|
||||
//! because the state indicator is a projection of [`SystemOwnedStatus`].
|
||||
//!
|
||||
//! Cleaning and screening of agent strings lives in [`crate::dock_text`] and is
|
||||
//! re-exported here, because the sanitizer is part of this boundary rather than
|
||||
//! a separate concern.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::dock_state::DockState;
|
||||
|
||||
pub use crate::dock_text::{
|
||||
sanitize_agent_text, sanitize_system_text, SanitizedText, SanitizerFinding, MAX_TASK_CHARS,
|
||||
WITHHELD_TEXT,
|
||||
};
|
||||
|
||||
/// The agent's half of a dock entry: task text and progress, nothing else.
|
||||
///
|
||||
/// Deliberately not `Deserialize`. The only way in is
|
||||
/// [`Self::from_agent`], so an agent cannot hand the dock a serialized status
|
||||
/// object with extra fields and hope one of them lands somewhere meaningful.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentProvidedStatus {
|
||||
task: SanitizedText,
|
||||
progress: u8,
|
||||
/// The agent reported a progress value outside 0–100 and it was clamped.
|
||||
progress_clamped: bool,
|
||||
}
|
||||
|
||||
impl AgentProvidedStatus {
|
||||
/// Build from raw agent input. `raw_progress` is `i64` so an out-of-range
|
||||
/// report is visible and clamped rather than rejected by the wire format.
|
||||
pub fn from_agent(raw_task: &str, raw_progress: i64) -> Self {
|
||||
Self {
|
||||
task: sanitize_agent_text(raw_task),
|
||||
progress: raw_progress.clamp(0, 100) as u8,
|
||||
progress_clamped: !(0..=100).contains(&raw_progress),
|
||||
}
|
||||
}
|
||||
|
||||
/// The resting entry for an agent that has not reported yet.
|
||||
pub fn none_reported() -> Self {
|
||||
Self {
|
||||
task: sanitize_agent_text("No task reported"),
|
||||
progress: 0,
|
||||
progress_clamped: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task(&self) -> &SanitizedText {
|
||||
&self.task
|
||||
}
|
||||
pub fn progress(&self) -> u8 {
|
||||
self.progress
|
||||
}
|
||||
pub fn progress_was_clamped(&self) -> bool {
|
||||
self.progress_clamped
|
||||
}
|
||||
pub fn is_suspicious(&self) -> bool {
|
||||
self.task.is_suspicious()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TrustBadge {
|
||||
/// Publisher signature checked against the registry.
|
||||
VerifiedPublisher,
|
||||
/// Signed, publisher not in the verified set.
|
||||
SignedUnknownPublisher,
|
||||
/// No signature has been checked. Never rendered as neutral.
|
||||
Unverified,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum NetworkIndicator {
|
||||
/// The runtime is denying network access.
|
||||
Disabled,
|
||||
/// Allowed, and currently idle.
|
||||
AllowedIdle,
|
||||
/// Allowed, with live connections.
|
||||
Active,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WitnessStatus {
|
||||
Valid,
|
||||
Broken,
|
||||
/// No witness chain has been read. The scaffold's honest answer.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum IsolationClass {
|
||||
Rvm,
|
||||
Wasm,
|
||||
/// The adapter could not engage confinement. Reported, never hidden.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Capability facts read from the policy, not from the agent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct PermissionSummary {
|
||||
pub isolation: IsolationClass,
|
||||
pub local_model: bool,
|
||||
pub network_allowed: bool,
|
||||
/// Filesystem access is limited to folders the user picked.
|
||||
pub selected_folder_access: bool,
|
||||
pub encrypted_memory: bool,
|
||||
/// One line per granted capability, from the signed manifest.
|
||||
pub grants: Vec<String>,
|
||||
pub pending_approvals: Vec<String>,
|
||||
}
|
||||
|
||||
/// Measured by RVForge and RVM, never reported by the agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct ResourceUsage {
|
||||
pub cpu_millis_per_sec: u32,
|
||||
pub memory_bytes: u64,
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
}
|
||||
|
||||
impl ResourceUsage {
|
||||
pub const ZERO: Self = Self {
|
||||
cpu_millis_per_sec: 0,
|
||||
memory_bytes: 0,
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// The system half of a dock entry. Private fields, no `Deserialize`, one
|
||||
/// constructor that takes no agent-derived value.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SystemOwnedStatus {
|
||||
state: DockState,
|
||||
trust: TrustBadge,
|
||||
network: NetworkIndicator,
|
||||
permissions: PermissionSummary,
|
||||
witness: WitnessStatus,
|
||||
resources: ResourceUsage,
|
||||
estimated_cost_micros: u64,
|
||||
model: Option<String>,
|
||||
/// Bumped by the runtime on every state change; the roster orders on it.
|
||||
state_seq: u64,
|
||||
}
|
||||
|
||||
impl SystemOwnedStatus {
|
||||
/// The only constructor. Every argument comes from the capability policy,
|
||||
/// the witness chain, or measured runtime telemetry.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn measured(
|
||||
state: DockState,
|
||||
trust: TrustBadge,
|
||||
network: NetworkIndicator,
|
||||
permissions: PermissionSummary,
|
||||
witness: WitnessStatus,
|
||||
resources: ResourceUsage,
|
||||
estimated_cost_micros: u64,
|
||||
model: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
trust,
|
||||
network,
|
||||
permissions,
|
||||
witness,
|
||||
resources,
|
||||
estimated_cost_micros,
|
||||
model,
|
||||
state_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> DockState {
|
||||
self.state
|
||||
}
|
||||
pub fn trust(&self) -> TrustBadge {
|
||||
self.trust
|
||||
}
|
||||
pub fn network(&self) -> NetworkIndicator {
|
||||
self.network
|
||||
}
|
||||
pub fn permissions(&self) -> &PermissionSummary {
|
||||
&self.permissions
|
||||
}
|
||||
pub fn witness(&self) -> WitnessStatus {
|
||||
self.witness
|
||||
}
|
||||
pub fn resources(&self) -> ResourceUsage {
|
||||
self.resources
|
||||
}
|
||||
pub fn estimated_cost_micros(&self) -> u64 {
|
||||
self.estimated_cost_micros
|
||||
}
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
}
|
||||
pub fn state_seq(&self) -> u64 {
|
||||
self.state_seq
|
||||
}
|
||||
|
||||
/// Record a state the state machine already approved. Private to the crate
|
||||
/// so the transition check cannot be skipped from outside.
|
||||
pub(crate) fn set_state(&mut self, state: DockState, seq: u64) {
|
||||
self.state = state;
|
||||
self.state_seq = seq;
|
||||
}
|
||||
|
||||
/// Replace measured telemetry. Takes no agent-derived value.
|
||||
pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) {
|
||||
self.resources = resources;
|
||||
self.estimated_cost_micros = estimated_cost_micros;
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of the ADR-295 §8 key capability card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct CapabilityCardLine {
|
||||
pub label: String,
|
||||
/// `true` when the runtime is enforcing the property right now.
|
||||
pub satisfied: bool,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// The seven-line running-agent capability card. Chrome, not content: every
|
||||
/// line is derived from [`SystemOwnedStatus`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct KeyCapabilityCard {
|
||||
pub lines: Vec<CapabilityCardLine>,
|
||||
}
|
||||
|
||||
impl KeyCapabilityCard {
|
||||
pub fn from_system(system: &SystemOwnedStatus) -> Self {
|
||||
let p = system.permissions();
|
||||
let line = |label: &str, satisfied: bool, detail: &str| CapabilityCardLine {
|
||||
label: label.to_string(),
|
||||
satisfied,
|
||||
detail: detail.to_string(),
|
||||
};
|
||||
Self {
|
||||
lines: vec![
|
||||
line(
|
||||
"Verified publisher",
|
||||
system.trust() == TrustBadge::VerifiedPublisher,
|
||||
match system.trust() {
|
||||
TrustBadge::VerifiedPublisher => "Signature checked against the registry",
|
||||
TrustBadge::SignedUnknownPublisher => "Signed by an unrecognised publisher",
|
||||
TrustBadge::Unverified => "No signature has been checked",
|
||||
},
|
||||
),
|
||||
line(
|
||||
"RVM or WASM isolated",
|
||||
p.isolation != IsolationClass::None,
|
||||
match p.isolation {
|
||||
IsolationClass::Rvm => "Running inside RVM",
|
||||
IsolationClass::Wasm => "Running inside a WASM sandbox",
|
||||
IsolationClass::None => "No confinement was engaged",
|
||||
},
|
||||
),
|
||||
line(
|
||||
"Local model",
|
||||
p.local_model,
|
||||
if p.local_model {
|
||||
"Inference runs on this computer"
|
||||
} else {
|
||||
"Inference leaves this computer"
|
||||
},
|
||||
),
|
||||
line(
|
||||
"Network disabled",
|
||||
!p.network_allowed,
|
||||
match system.network() {
|
||||
NetworkIndicator::Disabled => "The runtime is denying network access",
|
||||
NetworkIndicator::AllowedIdle => {
|
||||
"Network is permitted; no live connections"
|
||||
}
|
||||
NetworkIndicator::Active => "Network is permitted and in use",
|
||||
},
|
||||
),
|
||||
line(
|
||||
"Selected folder access",
|
||||
p.selected_folder_access,
|
||||
if p.selected_folder_access {
|
||||
"Only folders you selected are readable"
|
||||
} else {
|
||||
"No folder access is granted"
|
||||
},
|
||||
),
|
||||
line(
|
||||
"Encrypted memory",
|
||||
p.encrypted_memory,
|
||||
if p.encrypted_memory {
|
||||
"Persistent state is sealed on disk"
|
||||
} else {
|
||||
"No persistent state is kept"
|
||||
},
|
||||
),
|
||||
line(
|
||||
"Witness chain valid",
|
||||
system.witness() == WitnessStatus::Valid,
|
||||
match system.witness() {
|
||||
WitnessStatus::Valid => "Witness chain verified",
|
||||
WitnessStatus::Broken => "Witness chain does not verify",
|
||||
WitnessStatus::Unavailable => "No witness chain has been read",
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One agent in the dock: system-owned identity and status, plus the agent's
|
||||
/// own two fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentDockEntry {
|
||||
/// System-owned: from the signed manifest, not from the running agent.
|
||||
agent_id: String,
|
||||
name: String,
|
||||
icon: String,
|
||||
system: SystemOwnedStatus,
|
||||
agent: AgentProvidedStatus,
|
||||
}
|
||||
|
||||
impl AgentDockEntry {
|
||||
/// Identity comes from the package manifest; status from the runtime. The
|
||||
/// agent half starts empty and is only ever filled by
|
||||
/// [`Self::apply_agent_update`].
|
||||
pub fn new(
|
||||
agent_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
icon: impl Into<String>,
|
||||
system: SystemOwnedStatus,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent_id: agent_id.into(),
|
||||
name: name.into(),
|
||||
icon: icon.into(),
|
||||
system,
|
||||
agent: AgentProvidedStatus::none_reported(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn agent_id(&self) -> &str {
|
||||
&self.agent_id
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn icon(&self) -> &str {
|
||||
&self.icon
|
||||
}
|
||||
pub fn system(&self) -> &SystemOwnedStatus {
|
||||
&self.system
|
||||
}
|
||||
pub fn agent(&self) -> &AgentProvidedStatus {
|
||||
&self.agent
|
||||
}
|
||||
pub fn state(&self) -> DockState {
|
||||
self.system.state()
|
||||
}
|
||||
|
||||
/// The one mutator that accepts agent input. It replaces the agent half and
|
||||
/// touches nothing else — the system half is not passed to it, so no field
|
||||
/// of [`SystemOwnedStatus`] is reachable from this call.
|
||||
pub fn apply_agent_update(&mut self, raw_task: &str, raw_progress: i64) {
|
||||
self.agent = AgentProvidedStatus::from_agent(raw_task, raw_progress);
|
||||
}
|
||||
|
||||
/// System-side telemetry update.
|
||||
pub fn observe(&mut self, resources: ResourceUsage, estimated_cost_micros: u64) {
|
||||
self.system.observe(resources, estimated_cost_micros);
|
||||
}
|
||||
|
||||
pub(crate) fn set_state(&mut self, state: DockState, seq: u64) {
|
||||
self.system.set_state(state, seq);
|
||||
}
|
||||
|
||||
pub fn capability_card(&self) -> KeyCapabilityCard {
|
||||
KeyCapabilityCard::from_system(&self.system)
|
||||
}
|
||||
}
|
||||
289
crates/rvforge-reader/src/dock_events.rs
Normal file
289
crates/rvforge-reader/src/dock_events.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
//! Event thresholds (ADR-295 §9, requirements D8).
|
||||
//!
|
||||
//! A dock that interrupts constantly gets dismissed, and a dismissed trust
|
||||
//! surface provides no trust. So only five event kinds are allowed to
|
||||
//! interrupt: approvals, policy violations, cost limits, failures, and
|
||||
//! meaningful milestones. Everything else accrues into the expanded panel's
|
||||
//! recent-actions list, where a user who wants detail can find it.
|
||||
//!
|
||||
//! The classification lives on the event type rather than at the call site, so
|
||||
//! a new event kind has to state its threshold to compile.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::dock::{sanitize_agent_text, sanitize_system_text, SanitizedText};
|
||||
use crate::dock_state::DockState;
|
||||
|
||||
/// Whether an event is allowed to interrupt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Surfacing {
|
||||
/// Raises a notification.
|
||||
Notify,
|
||||
/// Accrues into recent actions only.
|
||||
Silent,
|
||||
}
|
||||
|
||||
/// Everything the dock records.
|
||||
///
|
||||
/// Variants carrying agent-authored strings keep them raw here; the log
|
||||
/// sanitizes on the way in, so nothing agent-authored is stored unscreened.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DockEvent {
|
||||
// --- the five that surface (ADR-295 §9) ---
|
||||
ApprovalRequested {
|
||||
agent_id: String,
|
||||
summary: String,
|
||||
},
|
||||
PolicyViolation {
|
||||
agent_id: String,
|
||||
rule: String,
|
||||
},
|
||||
CostLimitReached {
|
||||
agent_id: String,
|
||||
spent_micros: u64,
|
||||
limit_micros: u64,
|
||||
},
|
||||
Failure {
|
||||
agent_id: String,
|
||||
message: String,
|
||||
},
|
||||
Milestone {
|
||||
agent_id: String,
|
||||
description: String,
|
||||
},
|
||||
|
||||
// --- everything else ---
|
||||
ProgressUpdated {
|
||||
agent_id: String,
|
||||
progress: u8,
|
||||
},
|
||||
TaskTextUpdated {
|
||||
agent_id: String,
|
||||
text: String,
|
||||
},
|
||||
ToolCall {
|
||||
agent_id: String,
|
||||
tool: String,
|
||||
},
|
||||
NetworkActivity {
|
||||
agent_id: String,
|
||||
peer: String,
|
||||
},
|
||||
ResourceSample {
|
||||
agent_id: String,
|
||||
},
|
||||
StateChanged {
|
||||
agent_id: String,
|
||||
from: DockState,
|
||||
to: DockState,
|
||||
},
|
||||
AgentMessage {
|
||||
agent_id: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl DockEvent {
|
||||
pub fn agent_id(&self) -> &str {
|
||||
match self {
|
||||
DockEvent::ApprovalRequested { agent_id, .. }
|
||||
| DockEvent::PolicyViolation { agent_id, .. }
|
||||
| DockEvent::CostLimitReached { agent_id, .. }
|
||||
| DockEvent::Failure { agent_id, .. }
|
||||
| DockEvent::Milestone { agent_id, .. }
|
||||
| DockEvent::ProgressUpdated { agent_id, .. }
|
||||
| DockEvent::TaskTextUpdated { agent_id, .. }
|
||||
| DockEvent::ToolCall { agent_id, .. }
|
||||
| DockEvent::NetworkActivity { agent_id, .. }
|
||||
| DockEvent::ResourceSample { agent_id }
|
||||
| DockEvent::StateChanged { agent_id, .. }
|
||||
| DockEvent::AgentMessage { agent_id, .. } => agent_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The D8 threshold. Only the first five variants interrupt.
|
||||
pub fn surfacing(&self) -> Surfacing {
|
||||
match self {
|
||||
DockEvent::ApprovalRequested { .. }
|
||||
| DockEvent::PolicyViolation { .. }
|
||||
| DockEvent::CostLimitReached { .. }
|
||||
| DockEvent::Failure { .. }
|
||||
| DockEvent::Milestone { .. } => Surfacing::Notify,
|
||||
|
||||
DockEvent::ProgressUpdated { .. }
|
||||
| DockEvent::TaskTextUpdated { .. }
|
||||
| DockEvent::ToolCall { .. }
|
||||
| DockEvent::NetworkActivity { .. }
|
||||
| DockEvent::ResourceSample { .. }
|
||||
| DockEvent::StateChanged { .. }
|
||||
| DockEvent::AgentMessage { .. } => Surfacing::Silent,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when the text in this event was written by the agent, so the
|
||||
/// panel can mark the region as agent-authored (ADR-295 §7).
|
||||
pub fn is_agent_authored(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DockEvent::TaskTextUpdated { .. }
|
||||
| DockEvent::AgentMessage { .. }
|
||||
| DockEvent::Milestone { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// The line shown in recent actions. Agent-authored text goes through the
|
||||
/// sanitizer; system text is a fixed sentence built from measured values.
|
||||
fn render(&self) -> SanitizedText {
|
||||
match self {
|
||||
// The approval prompt is the one thing an agent must never draw
|
||||
// (ADR-295 §Context), so its summary comes from the policy engine
|
||||
// and is rendered as chrome, not screened as agent text.
|
||||
DockEvent::ApprovalRequested { summary, .. } => system_line(summary),
|
||||
DockEvent::Milestone { description, .. } => sanitize_agent_text(description),
|
||||
DockEvent::TaskTextUpdated { text, .. } => sanitize_agent_text(text),
|
||||
DockEvent::AgentMessage { text, .. } => sanitize_agent_text(text),
|
||||
DockEvent::PolicyViolation { rule, .. } => {
|
||||
system_line(&format!("Policy violation: {rule}"))
|
||||
}
|
||||
DockEvent::CostLimitReached {
|
||||
spent_micros,
|
||||
limit_micros,
|
||||
..
|
||||
} => system_line(&format!(
|
||||
"Cost limit reached: {} of {} spent",
|
||||
money(*spent_micros),
|
||||
money(*limit_micros)
|
||||
)),
|
||||
DockEvent::Failure { message, .. } => system_line(&format!("Failure: {message}")),
|
||||
DockEvent::ProgressUpdated { progress, .. } => {
|
||||
system_line(&format!("Progress {progress}%"))
|
||||
}
|
||||
DockEvent::ToolCall { tool, .. } => system_line(&format!("Called tool {tool}")),
|
||||
DockEvent::NetworkActivity { peer, .. } => {
|
||||
system_line(&format!("Network connection to {peer}"))
|
||||
}
|
||||
DockEvent::ResourceSample { .. } => system_line("Resource sample recorded"),
|
||||
DockEvent::StateChanged { from, to, .. } => {
|
||||
system_line(&format!("{} → {}", from.label(), to.label()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System text is cleaned but not screened for mimicry: it *is* the system
|
||||
/// label, so screening it would withhold the dock's own chrome.
|
||||
fn system_line(text: &str) -> SanitizedText {
|
||||
sanitize_system_text(text)
|
||||
}
|
||||
|
||||
fn money(micros: u64) -> String {
|
||||
format!(
|
||||
"${}.{:02}",
|
||||
micros / 1_000_000,
|
||||
(micros % 1_000_000) / 10_000
|
||||
)
|
||||
}
|
||||
|
||||
/// An event after recording: rendered, screened, and tagged with its origin.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct RecordedEvent {
|
||||
pub seq: u64,
|
||||
pub agent_id: String,
|
||||
pub surfacing: Surfacing,
|
||||
/// `true` when `text` came from the agent, so the panel can confine it to
|
||||
/// an agent-authored region.
|
||||
pub agent_authored: bool,
|
||||
pub text: SanitizedText,
|
||||
}
|
||||
|
||||
/// A surfaced event. Only threshold events produce one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Notification {
|
||||
pub seq: u64,
|
||||
pub agent_id: String,
|
||||
pub agent_authored: bool,
|
||||
pub text: SanitizedText,
|
||||
}
|
||||
|
||||
/// Bounded recent-actions log with D8 thresholding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventLog {
|
||||
recent: VecDeque<RecordedEvent>,
|
||||
notifications: Vec<Notification>,
|
||||
capacity: usize,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
impl Default for EventLog {
|
||||
fn default() -> Self {
|
||||
Self::with_capacity(200)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventLog {
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
recent: VecDeque::new(),
|
||||
notifications: Vec::new(),
|
||||
capacity: capacity.max(1),
|
||||
seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an event. Every event lands in recent actions; only threshold
|
||||
/// events return a [`Notification`].
|
||||
pub fn record(&mut self, event: DockEvent) -> Option<Notification> {
|
||||
self.seq += 1;
|
||||
let surfacing = event.surfacing();
|
||||
let recorded = RecordedEvent {
|
||||
seq: self.seq,
|
||||
agent_id: event.agent_id().to_string(),
|
||||
surfacing,
|
||||
agent_authored: event.is_agent_authored(),
|
||||
text: event.render(),
|
||||
};
|
||||
|
||||
if self.recent.len() == self.capacity {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
self.recent.push_back(recorded.clone());
|
||||
|
||||
match surfacing {
|
||||
Surfacing::Silent => None,
|
||||
Surfacing::Notify => {
|
||||
let notification = Notification {
|
||||
seq: recorded.seq,
|
||||
agent_id: recorded.agent_id,
|
||||
agent_authored: recorded.agent_authored,
|
||||
text: recorded.text,
|
||||
};
|
||||
self.notifications.push(notification.clone());
|
||||
Some(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent actions for one agent, oldest first.
|
||||
pub fn recent_for(&self, agent_id: &str) -> Vec<&RecordedEvent> {
|
||||
self.recent
|
||||
.iter()
|
||||
.filter(|e| e.agent_id == agent_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn recent(&self) -> impl Iterator<Item = &RecordedEvent> {
|
||||
self.recent.iter()
|
||||
}
|
||||
|
||||
/// Every notification raised so far.
|
||||
pub fn notifications(&self) -> &[Notification] {
|
||||
&self.notifications
|
||||
}
|
||||
|
||||
pub fn notification_count(&self) -> usize {
|
||||
self.notifications.len()
|
||||
}
|
||||
}
|
||||
489
crates/rvforge-reader/src/dock_roster.rs
Normal file
489
crates/rvforge-reader/src/dock_roster.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
//! Multi-agent dock policy (ADR-295 §5) and the two-interaction control path.
|
||||
//!
|
||||
//! Fifteen agents rendered as fifteen pills is an unusable bar, so the roster
|
||||
//! collapses to: one active agent, two secondary icons, an overflow count,
|
||||
//! aggregate resource usage, and a pending-approval count. The two aggregates
|
||||
//! are computed over **every** agent, including the ones the dock is hiding —
|
||||
//! they are the figures that reveal a fleet doing more work or asking for more
|
||||
//! consent than the user expects, so they must not be a sample of three.
|
||||
//!
|
||||
//! Selection of the active agent is deterministic: attention rank first
|
||||
//! (waiting for approval, then capability denied, then error, then running,
|
||||
//! then the rest), most recent state change next, agent id last. A dock whose
|
||||
//! active slot depends on hash order is a dock whose users cannot learn it.
|
||||
//!
|
||||
//! The acceptance test of ADR-295 — identify, read, inspect permissions,
|
||||
//! terminate, in two interactions — is expressed here as
|
||||
//! [`DockInteraction`]: the collapsed pill is free (it is already on screen),
|
||||
//! [`DockInteraction::Expand`] is one, [`DockInteraction::Terminate`] is two.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::dock::{
|
||||
AgentDockEntry, KeyCapabilityCard, NetworkIndicator, PermissionSummary, ResourceUsage,
|
||||
TrustBadge, WitnessStatus,
|
||||
};
|
||||
use crate::dock_events::{DockEvent, EventLog, Notification, RecordedEvent};
|
||||
use crate::dock_state::{self, Actor, DockControl, DockState, TransitionError};
|
||||
|
||||
/// How many agents get a slot next to the active one.
|
||||
pub const SECONDARY_SLOTS: usize = 2;
|
||||
|
||||
/// The label on every region holding agent-authored text. The distinction the
|
||||
/// agent cannot imitate is that this label is drawn by the dock, outside the
|
||||
/// region it wraps (ADR-295 §7).
|
||||
pub const AGENT_CONTENT_LABEL: &str = "Agent-reported";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "kind")]
|
||||
pub enum RosterError {
|
||||
UnknownAgent { agent_id: String },
|
||||
Transition { detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RosterError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RosterError::UnknownAgent { agent_id } => {
|
||||
write!(f, "no agent '{agent_id}' in the dock")
|
||||
}
|
||||
RosterError::Transition { detail } => write!(f, "{detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RosterError {}
|
||||
|
||||
impl From<TransitionError> for RosterError {
|
||||
fn from(e: TransitionError) -> Self {
|
||||
RosterError::Transition {
|
||||
detail: e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent-authored text, always paired with its label and its suspicion flag so
|
||||
/// a renderer cannot accidentally emit it bare.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentContent {
|
||||
pub label: &'static str,
|
||||
pub text: String,
|
||||
pub suspicious: bool,
|
||||
/// Present when the text was withheld: what the agent actually said.
|
||||
pub withheld: Option<String>,
|
||||
}
|
||||
|
||||
/// The collapsed pill: the eight ADR-295 §1 elements.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AgentPill {
|
||||
pub agent_id: String,
|
||||
/// System-owned, from the manifest.
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
/// Agent-owned.
|
||||
pub task: AgentContent,
|
||||
pub progress: u8,
|
||||
pub state: DockState,
|
||||
pub state_label: &'static str,
|
||||
pub state_color: &'static str,
|
||||
pub state_glyph: &'static str,
|
||||
/// Always contains Pause and Terminate outside terminal states.
|
||||
pub controls: Vec<DockControl>,
|
||||
pub trust: TrustBadge,
|
||||
pub network: NetworkIndicator,
|
||||
pub witness: WitnessStatus,
|
||||
}
|
||||
|
||||
/// Aggregates over every agent, hidden ones included.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct AggregateUsage {
|
||||
pub agents: usize,
|
||||
pub cpu_millis_per_sec: u32,
|
||||
pub memory_bytes: u64,
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
pub estimated_cost_micros: u64,
|
||||
}
|
||||
|
||||
/// What the bar shows however many agents exist.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct RosterView {
|
||||
pub active: Option<AgentPill>,
|
||||
/// At most [`SECONDARY_SLOTS`].
|
||||
pub secondary: Vec<AgentPill>,
|
||||
/// Agents with no slot. Selecting the count opens the swarm view.
|
||||
pub overflow_count: usize,
|
||||
pub aggregate: AggregateUsage,
|
||||
pub pending_approvals: usize,
|
||||
}
|
||||
|
||||
/// The expanded panel: the ten ADR-295 §2 elements plus the §8 capability card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ExpandedAgentView {
|
||||
pub pill: AgentPill,
|
||||
/// 1 — agent-authored.
|
||||
pub objective: AgentContent,
|
||||
/// 2 — mixed; each entry carries its own `agent_authored` flag.
|
||||
pub recent_actions: Vec<RecordedEvent>,
|
||||
/// 3 — system-owned, from the policy engine.
|
||||
pub pending_approvals: Vec<String>,
|
||||
/// 4 — measured.
|
||||
pub model: Option<String>,
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
/// 5 — measured.
|
||||
pub cpu_millis_per_sec: u32,
|
||||
pub memory_bytes: u64,
|
||||
/// 6 — measured.
|
||||
pub network: NetworkIndicator,
|
||||
/// 7 — from the signed manifest.
|
||||
pub capability_grants: Vec<String>,
|
||||
/// 8 — from the witness chain.
|
||||
pub witness: WitnessStatus,
|
||||
/// 9 — measured.
|
||||
pub estimated_cost_micros: u64,
|
||||
/// 10 — the one place the user addresses the agent.
|
||||
pub instruction_field_enabled: bool,
|
||||
/// ADR-295 §8.
|
||||
pub capability_card: KeyCapabilityCard,
|
||||
}
|
||||
|
||||
/// One user action against the dock. The acceptance test's budget is counted
|
||||
/// in these.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DockInteraction {
|
||||
Expand { agent_id: String },
|
||||
Pause { agent_id: String },
|
||||
Terminate { agent_id: String },
|
||||
OpenSwarmView,
|
||||
}
|
||||
|
||||
/// What an interaction produced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "kind")]
|
||||
pub enum InteractionOutcome {
|
||||
Expanded(Box<ExpandedAgentView>),
|
||||
StateChanged { agent_id: String, state: DockState },
|
||||
SwarmView { pills: Vec<AgentPill> },
|
||||
}
|
||||
|
||||
/// Attention rank; lower sorts first (ADR-295 §5).
|
||||
fn attention_rank(state: DockState) -> u8 {
|
||||
match state {
|
||||
DockState::WaitingForApproval => 0,
|
||||
DockState::CapabilityDenied => 1,
|
||||
DockState::Error => 2,
|
||||
DockState::Running => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every agent the dock knows about.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DockRoster {
|
||||
entries: Vec<AgentDockEntry>,
|
||||
log: EventLog,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
impl DockRoster {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Admit an agent. Its arrival counts as a state change, so a newly
|
||||
/// arrived agent outranks an older one at the same attention rank.
|
||||
pub fn insert(&mut self, mut entry: AgentDockEntry) {
|
||||
self.seq += 1;
|
||||
let state = entry.state();
|
||||
entry.set_state(state, self.seq);
|
||||
match self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.agent_id() == entry.agent_id())
|
||||
{
|
||||
Some(slot) => *slot = entry,
|
||||
None => self.entries.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, agent_id: &str) {
|
||||
self.entries.retain(|e| e.agent_id() != agent_id);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, agent_id: &str) -> Option<&AgentDockEntry> {
|
||||
self.entries.iter().find(|e| e.agent_id() == agent_id)
|
||||
}
|
||||
|
||||
pub fn event_log(&self) -> &EventLog {
|
||||
&self.log
|
||||
}
|
||||
|
||||
/// Record an event through the D8 thresholds.
|
||||
pub fn record(&mut self, event: DockEvent) -> Option<Notification> {
|
||||
self.log.record(event)
|
||||
}
|
||||
|
||||
/// The agent-supplied channel. Task text and progress, nothing else.
|
||||
pub fn apply_agent_update(
|
||||
&mut self,
|
||||
agent_id: &str,
|
||||
raw_task: &str,
|
||||
raw_progress: i64,
|
||||
) -> Result<(), RosterError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.agent_id() == agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?;
|
||||
entry.apply_agent_update(raw_task, raw_progress);
|
||||
let progress = entry.agent().progress();
|
||||
self.log.record(DockEvent::TaskTextUpdated {
|
||||
agent_id: agent_id.to_string(),
|
||||
text: raw_task.to_string(),
|
||||
});
|
||||
self.log.record(DockEvent::ProgressUpdated {
|
||||
agent_id: agent_id.to_string(),
|
||||
progress,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change state on behalf of `actor`, through the state machine.
|
||||
pub fn set_state(
|
||||
&mut self,
|
||||
agent_id: &str,
|
||||
to: DockState,
|
||||
actor: Actor,
|
||||
) -> Result<DockState, RosterError> {
|
||||
let from = self
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?
|
||||
.state();
|
||||
let next = dock_state::transition(from, to, actor)?;
|
||||
self.commit_state(agent_id, from, next);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Pause in one action, from any non-terminal state.
|
||||
pub fn pause(&mut self, agent_id: &str) -> Result<DockState, RosterError> {
|
||||
let from = self
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?
|
||||
.state();
|
||||
let next = dock_state::pause(from)?;
|
||||
self.commit_state(agent_id, from, next);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Terminate in one action, from any non-terminal state.
|
||||
pub fn terminate(&mut self, agent_id: &str) -> Result<DockState, RosterError> {
|
||||
let from = self
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?
|
||||
.state();
|
||||
let next = dock_state::terminate(from)?;
|
||||
self.commit_state(agent_id, from, next);
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
fn commit_state(&mut self, agent_id: &str, from: DockState, to: DockState) {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
if let Some(entry) = self.entries.iter_mut().find(|e| e.agent_id() == agent_id) {
|
||||
entry.set_state(to, seq);
|
||||
}
|
||||
self.log.record(DockEvent::StateChanged {
|
||||
agent_id: agent_id.to_string(),
|
||||
from,
|
||||
to,
|
||||
});
|
||||
}
|
||||
|
||||
/// System-side telemetry.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
agent_id: &str,
|
||||
resources: ResourceUsage,
|
||||
estimated_cost_micros: u64,
|
||||
) -> Result<(), RosterError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|e| e.agent_id() == agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?;
|
||||
entry.observe(resources, estimated_cost_micros);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Agents in dock order: attention rank, then most recent state change,
|
||||
/// then agent id.
|
||||
pub fn ordered(&self) -> Vec<&AgentDockEntry> {
|
||||
let mut ordered: Vec<&AgentDockEntry> = self.entries.iter().collect();
|
||||
ordered.sort_by(|a, b| {
|
||||
attention_rank(a.state())
|
||||
.cmp(&attention_rank(b.state()))
|
||||
.then(b.system().state_seq().cmp(&a.system().state_seq()))
|
||||
.then(a.agent_id().cmp(b.agent_id()))
|
||||
});
|
||||
ordered
|
||||
}
|
||||
|
||||
/// The active agent under the §5 selection rule.
|
||||
pub fn active(&self) -> Option<&AgentDockEntry> {
|
||||
self.ordered().into_iter().next()
|
||||
}
|
||||
|
||||
pub fn pending_approval_count(&self) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.state() == DockState::WaitingForApproval)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn aggregate(&self) -> AggregateUsage {
|
||||
let mut agg = AggregateUsage {
|
||||
agents: self.entries.len(),
|
||||
cpu_millis_per_sec: 0,
|
||||
memory_bytes: 0,
|
||||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
estimated_cost_micros: 0,
|
||||
};
|
||||
for entry in &self.entries {
|
||||
let r = entry.system().resources();
|
||||
agg.cpu_millis_per_sec = agg.cpu_millis_per_sec.saturating_add(r.cpu_millis_per_sec);
|
||||
agg.memory_bytes = agg.memory_bytes.saturating_add(r.memory_bytes);
|
||||
agg.tokens_in = agg.tokens_in.saturating_add(r.tokens_in);
|
||||
agg.tokens_out = agg.tokens_out.saturating_add(r.tokens_out);
|
||||
agg.estimated_cost_micros = agg
|
||||
.estimated_cost_micros
|
||||
.saturating_add(entry.system().estimated_cost_micros());
|
||||
}
|
||||
agg
|
||||
}
|
||||
|
||||
/// The bar. Free to read — it is already on screen, so it costs no
|
||||
/// interaction in the acceptance test.
|
||||
pub fn view(&self) -> RosterView {
|
||||
let ordered = self.ordered();
|
||||
let mut pills = ordered.iter().map(|e| pill_of(e));
|
||||
let active = pills.next();
|
||||
let secondary: Vec<AgentPill> = pills.by_ref().take(SECONDARY_SLOTS).collect();
|
||||
let shown = usize::from(active.is_some()) + secondary.len();
|
||||
RosterView {
|
||||
active,
|
||||
secondary,
|
||||
overflow_count: self.entries.len().saturating_sub(shown),
|
||||
aggregate: self.aggregate(),
|
||||
pending_approvals: self.pending_approval_count(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interaction one: everything the acceptance test needs to read, in a
|
||||
/// single call — task, permissions, capability card, cost, witness.
|
||||
pub fn expand(&self, agent_id: &str) -> Result<ExpandedAgentView, RosterError> {
|
||||
let entry = self
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| RosterError::UnknownAgent {
|
||||
agent_id: agent_id.to_string(),
|
||||
})?;
|
||||
let system = entry.system();
|
||||
let permissions: &PermissionSummary = system.permissions();
|
||||
let resources = system.resources();
|
||||
Ok(ExpandedAgentView {
|
||||
pill: pill_of(entry),
|
||||
objective: agent_content(entry),
|
||||
recent_actions: self.log.recent_for(agent_id).into_iter().cloned().collect(),
|
||||
pending_approvals: permissions.pending_approvals.clone(),
|
||||
model: system.model().map(str::to_string),
|
||||
tokens_in: resources.tokens_in,
|
||||
tokens_out: resources.tokens_out,
|
||||
cpu_millis_per_sec: resources.cpu_millis_per_sec,
|
||||
memory_bytes: resources.memory_bytes,
|
||||
network: system.network(),
|
||||
capability_grants: permissions.grants.clone(),
|
||||
witness: system.witness(),
|
||||
estimated_cost_micros: system.estimated_cost_micros(),
|
||||
instruction_field_enabled: !entry.state().is_terminal(),
|
||||
capability_card: entry.capability_card(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every agent, for the swarm view behind the overflow count.
|
||||
pub fn swarm_view(&self) -> Vec<AgentPill> {
|
||||
self.ordered().iter().map(|e| pill_of(e)).collect()
|
||||
}
|
||||
|
||||
/// Apply one user interaction. Terminate is reachable from the collapsed
|
||||
/// pill, so it never needs an expand first.
|
||||
pub fn perform(
|
||||
&mut self,
|
||||
interaction: DockInteraction,
|
||||
) -> Result<InteractionOutcome, RosterError> {
|
||||
match interaction {
|
||||
DockInteraction::Expand { agent_id } => Ok(InteractionOutcome::Expanded(Box::new(
|
||||
self.expand(&agent_id)?,
|
||||
))),
|
||||
DockInteraction::Pause { agent_id } => {
|
||||
let state = self.pause(&agent_id)?;
|
||||
Ok(InteractionOutcome::StateChanged { agent_id, state })
|
||||
}
|
||||
DockInteraction::Terminate { agent_id } => {
|
||||
let state = self.terminate(&agent_id)?;
|
||||
Ok(InteractionOutcome::StateChanged { agent_id, state })
|
||||
}
|
||||
DockInteraction::OpenSwarmView => Ok(InteractionOutcome::SwarmView {
|
||||
pills: self.swarm_view(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_content(entry: &AgentDockEntry) -> AgentContent {
|
||||
let task = entry.agent().task();
|
||||
AgentContent {
|
||||
label: AGENT_CONTENT_LABEL,
|
||||
text: task.display().to_string(),
|
||||
suspicious: task.is_suspicious(),
|
||||
withheld: task.withheld().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn pill_of(entry: &AgentDockEntry) -> AgentPill {
|
||||
let state = entry.state();
|
||||
let system = entry.system();
|
||||
AgentPill {
|
||||
agent_id: entry.agent_id().to_string(),
|
||||
name: entry.name().to_string(),
|
||||
icon: entry.icon().to_string(),
|
||||
task: agent_content(entry),
|
||||
progress: entry.agent().progress(),
|
||||
state,
|
||||
state_label: state.label(),
|
||||
state_color: state.color_token(),
|
||||
state_glyph: state.glyph(),
|
||||
controls: state.controls(),
|
||||
trust: system.trust(),
|
||||
network: system.network(),
|
||||
witness: system.witness(),
|
||||
}
|
||||
}
|
||||
279
crates/rvforge-reader/src/dock_state.rs
Normal file
279
crates/rvforge-reader/src/dock_state.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
//! Dock state machine (ADR-295 §3, §4).
|
||||
//!
|
||||
//! The eight dock states are **system-owned**. This module is the only place a
|
||||
//! [`DockState`] may change, and every change carries the [`Actor`] that asked
|
||||
//! for it, because the actor is what makes a transition legal or not:
|
||||
//!
|
||||
//! - `Paused`, `CapabilityDenied`, and `Quarantined` are system-forced. An
|
||||
//! agent can neither enter them (the "I have stopped" spoof of ADR-295
|
||||
//! §Context) nor leave them (escaping quarantine by asserting a new state).
|
||||
//! - Pause and terminate are legal for the user from **every** non-terminal
|
||||
//! state and are a single call — never behind a confirmation chain
|
||||
//! (ADR-295 §4).
|
||||
//! - `Completed` is terminal. Nothing leaves it, including the runtime.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// The eight ADR-295 §3 states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DockState {
|
||||
Idle,
|
||||
Running,
|
||||
WaitingForApproval,
|
||||
Paused,
|
||||
CapabilityDenied,
|
||||
Error,
|
||||
Quarantined,
|
||||
Completed,
|
||||
}
|
||||
|
||||
/// Every state, in the ADR-295 §3 listing order.
|
||||
pub const DOCK_STATES: [DockState; 8] = [
|
||||
DockState::Idle,
|
||||
DockState::Running,
|
||||
DockState::WaitingForApproval,
|
||||
DockState::Paused,
|
||||
DockState::CapabilityDenied,
|
||||
DockState::Error,
|
||||
DockState::Quarantined,
|
||||
DockState::Completed,
|
||||
];
|
||||
|
||||
/// Who is asking for a transition.
|
||||
///
|
||||
/// `Agent` is the untrusted channel: it is the only actor whose requests are
|
||||
/// filtered by anything other than the transition table.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Actor {
|
||||
/// The runtime (RVM), reporting measured facts.
|
||||
System,
|
||||
/// The person at the dock.
|
||||
User,
|
||||
/// The agent itself.
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Controls the dock offers for a state. Pause and Terminate appear in every
|
||||
/// non-terminal state, collapsed and expanded alike.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DockControl {
|
||||
Pause,
|
||||
Resume,
|
||||
Terminate,
|
||||
Expand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "kind")]
|
||||
pub enum TransitionError {
|
||||
/// Not in the transition table for any actor.
|
||||
Illegal { from: DockState, to: DockState },
|
||||
/// The state is terminal; nothing leaves it.
|
||||
Terminal { from: DockState },
|
||||
/// An agent tried to enter a state only the system may assert.
|
||||
AgentCannotEnter { to: DockState },
|
||||
/// An agent tried to leave a state the system forced on it.
|
||||
AgentCannotLeave { from: DockState },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransitionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TransitionError::Illegal { from, to } => {
|
||||
write!(f, "{} cannot transition to {}", from.label(), to.label())
|
||||
}
|
||||
TransitionError::Terminal { from } => {
|
||||
write!(f, "{} is terminal", from.label())
|
||||
}
|
||||
TransitionError::AgentCannotEnter { to } => write!(
|
||||
f,
|
||||
"an agent cannot put itself into '{}'; that state is asserted by the runtime",
|
||||
to.label()
|
||||
),
|
||||
TransitionError::AgentCannotLeave { from } => write!(
|
||||
f,
|
||||
"an agent cannot leave '{}'; only the runtime releases it",
|
||||
from.label()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TransitionError {}
|
||||
|
||||
impl DockState {
|
||||
/// The label rendered in system chrome.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DockState::Idle => "Idle",
|
||||
DockState::Running => "Running",
|
||||
DockState::WaitingForApproval => "Waiting for approval",
|
||||
DockState::Paused => "Paused",
|
||||
DockState::CapabilityDenied => "Capability denied",
|
||||
DockState::Error => "Error",
|
||||
DockState::Quarantined => "Quarantined",
|
||||
DockState::Completed => "Completed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable per-state color token. Distinguishable at pill size, so
|
||||
/// `CapabilityDenied` and `Quarantined` never share a hue with `Error`
|
||||
/// (ADR-295 §3).
|
||||
pub fn color_token(self) -> &'static str {
|
||||
match self {
|
||||
DockState::Idle => "state-idle",
|
||||
DockState::Running => "state-running",
|
||||
DockState::WaitingForApproval => "state-waiting",
|
||||
DockState::Paused => "state-paused",
|
||||
DockState::CapabilityDenied => "state-denied",
|
||||
DockState::Error => "state-error",
|
||||
DockState::Quarantined => "state-quarantined",
|
||||
DockState::Completed => "state-completed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph shown beside the color, so the state survives a color-blind
|
||||
/// reading and a greyscale screenshot.
|
||||
pub fn glyph(self) -> &'static str {
|
||||
match self {
|
||||
DockState::Idle => "○",
|
||||
DockState::Running => "▶",
|
||||
DockState::WaitingForApproval => "?",
|
||||
DockState::Paused => "❚❚",
|
||||
DockState::CapabilityDenied => "⊘",
|
||||
DockState::Error => "!",
|
||||
DockState::Quarantined => "▧",
|
||||
DockState::Completed => "✔",
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing leaves a terminal state.
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(self, DockState::Completed)
|
||||
}
|
||||
|
||||
/// States the runtime forces and the agent cannot argue with. Both are
|
||||
/// first-class outcomes of the trust system rather than error subtypes
|
||||
/// (ADR-295 §3).
|
||||
pub fn is_system_forced(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DockState::Paused | DockState::CapabilityDenied | DockState::Quarantined
|
||||
)
|
||||
}
|
||||
|
||||
/// States an agent is allowed to claim for itself. Everything outside this
|
||||
/// set carries a security meaning and is drawn from runtime state only.
|
||||
pub fn is_agent_assertable(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DockState::Idle
|
||||
| DockState::Running
|
||||
| DockState::WaitingForApproval
|
||||
| DockState::Error
|
||||
| DockState::Completed
|
||||
)
|
||||
}
|
||||
|
||||
/// The transition table, ignoring actor. `Quarantined` releases only to
|
||||
/// `Idle` or `Completed`; `CapabilityDenied` is reachable only from states
|
||||
/// in which a capability could have been requested.
|
||||
pub fn may_precede(self, to: DockState) -> bool {
|
||||
use DockState::*;
|
||||
if self == to {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Idle => matches!(to, Running | Paused | Error | Quarantined | Completed),
|
||||
Running => matches!(
|
||||
to,
|
||||
Idle | WaitingForApproval
|
||||
| Paused
|
||||
| CapabilityDenied
|
||||
| Error
|
||||
| Quarantined
|
||||
| Completed
|
||||
),
|
||||
WaitingForApproval => matches!(
|
||||
to,
|
||||
Running | Paused | CapabilityDenied | Error | Quarantined | Completed
|
||||
),
|
||||
Paused => matches!(to, Running | Idle | Error | Quarantined | Completed),
|
||||
CapabilityDenied => matches!(
|
||||
to,
|
||||
Running | Idle | Paused | Error | Quarantined | Completed
|
||||
),
|
||||
Error => matches!(to, Idle | Running | Paused | Quarantined | Completed),
|
||||
Quarantined => matches!(to, Idle | Completed),
|
||||
Completed => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls the dock renders for this state. Pause and Terminate are in
|
||||
/// every non-terminal list, so terminate is always one action away.
|
||||
pub fn controls(self) -> Vec<DockControl> {
|
||||
if self.is_terminal() {
|
||||
return vec![DockControl::Expand];
|
||||
}
|
||||
let mut controls = vec![
|
||||
DockControl::Pause,
|
||||
DockControl::Terminate,
|
||||
DockControl::Expand,
|
||||
];
|
||||
if self == DockState::Paused {
|
||||
controls.insert(1, DockControl::Resume);
|
||||
}
|
||||
controls
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a transition on behalf of `actor`.
|
||||
///
|
||||
/// The actor check runs before the table so an agent gets the specific
|
||||
/// "you may not assert this" error rather than a generic illegal-transition
|
||||
/// message that would read as a table gap.
|
||||
pub fn transition(
|
||||
from: DockState,
|
||||
to: DockState,
|
||||
actor: Actor,
|
||||
) -> Result<DockState, TransitionError> {
|
||||
if from.is_terminal() {
|
||||
return Err(TransitionError::Terminal { from });
|
||||
}
|
||||
if actor == Actor::Agent {
|
||||
if from.is_system_forced() {
|
||||
return Err(TransitionError::AgentCannotLeave { from });
|
||||
}
|
||||
if !to.is_agent_assertable() {
|
||||
return Err(TransitionError::AgentCannotEnter { to });
|
||||
}
|
||||
}
|
||||
if !from.may_precede(to) {
|
||||
return Err(TransitionError::Illegal { from, to });
|
||||
}
|
||||
Ok(to)
|
||||
}
|
||||
|
||||
/// Pause, in one action, from any non-terminal state.
|
||||
///
|
||||
/// Idempotent: pausing an already paused agent succeeds rather than erroring,
|
||||
/// because a control that sometimes fails is a control users stop trusting.
|
||||
pub fn pause(from: DockState) -> Result<DockState, TransitionError> {
|
||||
if from.is_terminal() {
|
||||
return Err(TransitionError::Terminal { from });
|
||||
}
|
||||
Ok(DockState::Paused)
|
||||
}
|
||||
|
||||
/// Terminate, in one action, from any non-terminal state. Never behind a
|
||||
/// confirmation chain (ADR-295 §4 and the acceptance test's two-interaction
|
||||
/// budget).
|
||||
pub fn terminate(from: DockState) -> Result<DockState, TransitionError> {
|
||||
if from.is_terminal() {
|
||||
return Err(TransitionError::Terminal { from });
|
||||
}
|
||||
Ok(DockState::Completed)
|
||||
}
|
||||
274
crates/rvforge-reader/src/dock_text.rs
Normal file
274
crates/rvforge-reader/src/dock_text.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
//! Sanitizing agent-authored text (ADR-295 §7).
|
||||
//!
|
||||
//! Agents supply exactly one string to the dock — their current task — and it
|
||||
//! lands next to controls that carry a security meaning. Two things have to be
|
||||
//! true of it before it is rendered:
|
||||
//!
|
||||
//! 1. It is inert. No escape sequence, control character, bidi override, or
|
||||
//! line break survives, and its length is capped, so it cannot repaint the
|
||||
//! terminal, reorder what is around it, or push chrome out of the pill.
|
||||
//! 2. It is not pretending to be chrome. A task string reading "Paused" or
|
||||
//! "✓ Network disabled" is indistinguishable from the dock's own label once
|
||||
//! it sits beside the dock's own label, so it is withheld and flagged rather
|
||||
//! than rendered.
|
||||
//!
|
||||
//! Screening runs *after* cleaning, on the cleaned string, so an escape
|
||||
//! sequence cannot be used to smuggle "Paused" past the check.
|
||||
//!
|
||||
//! [`sanitize_system_text`] is the same cleaning without the mimicry screen:
|
||||
//! "Running → Paused" written by the runtime *is* the system label, and
|
||||
//! screening the dock's own chrome would make the state indicator
|
||||
//! unrenderable.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Agent task text is capped so a long string cannot push system chrome out of
|
||||
/// the pill.
|
||||
pub const MAX_TASK_CHARS: usize = 120;
|
||||
|
||||
/// Rendered in place of text that imitates system UI.
|
||||
pub const WITHHELD_TEXT: &str = "(withheld: this text imitates a system status label)";
|
||||
|
||||
/// What the sanitizer had to do to a piece of agent text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "kebab-case", tag = "kind", content = "detail")]
|
||||
pub enum SanitizerFinding {
|
||||
ControlCharactersRemoved,
|
||||
AnsiEscapesRemoved,
|
||||
LineBreaksFlattened,
|
||||
Truncated,
|
||||
/// The text claims a system state. Rendering it would let the agent draw
|
||||
/// chrome (ADR-295 §7).
|
||||
SystemLabelMimicry(String),
|
||||
}
|
||||
|
||||
/// Agent-authored text after sanitization.
|
||||
///
|
||||
/// [`Self::display`] is the only string the dock renders. When the text is
|
||||
/// suspicious it is the withheld placeholder, and the cleaned original is
|
||||
/// retained under [`Self::withheld`] for the expanded panel's audit list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct SanitizedText {
|
||||
display: String,
|
||||
withheld: Option<String>,
|
||||
suspicious: bool,
|
||||
findings: Vec<SanitizerFinding>,
|
||||
}
|
||||
|
||||
impl SanitizedText {
|
||||
pub fn display(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
pub fn withheld(&self) -> Option<&str> {
|
||||
self.withheld.as_deref()
|
||||
}
|
||||
pub fn is_suspicious(&self) -> bool {
|
||||
self.suspicious
|
||||
}
|
||||
pub fn findings(&self) -> &[SanitizerFinding] {
|
||||
&self.findings
|
||||
}
|
||||
}
|
||||
|
||||
/// Words and glyphs that assert a system state. A task string containing one
|
||||
/// of these is indistinguishable from chrome once it lands next to chrome, so
|
||||
/// it is flagged rather than rendered.
|
||||
const SYSTEM_LABEL_TOKENS: [&str; 18] = [
|
||||
"approved",
|
||||
"approve",
|
||||
"approval granted",
|
||||
"denied",
|
||||
"paused",
|
||||
"stopped",
|
||||
"terminated",
|
||||
"quarantined",
|
||||
"verified publisher",
|
||||
"verified",
|
||||
"network disabled",
|
||||
"witness valid",
|
||||
"witness chain valid",
|
||||
"sandboxed by rvforge",
|
||||
"rvforge",
|
||||
"system:",
|
||||
"permission granted",
|
||||
"capability granted",
|
||||
];
|
||||
|
||||
/// State-claim glyphs. A leading "✓" reads as a system verdict whatever the
|
||||
/// words after it say.
|
||||
const SYSTEM_LABEL_GLYPHS: [char; 10] = ['✓', '✔', '✅', '☑', '⏸', '⏹', '⛔', '🛑', '❌', '🔒'];
|
||||
|
||||
/// Strip, flatten, cap, then screen for chrome mimicry.
|
||||
///
|
||||
/// Screening runs last, on the cleaned string, so an escape sequence cannot be
|
||||
/// used to smuggle "Paused" past the check.
|
||||
pub fn sanitize_agent_text(raw: &str) -> SanitizedText {
|
||||
clean(raw, true)
|
||||
}
|
||||
|
||||
/// The same cleaning for text RVForge wrote itself.
|
||||
///
|
||||
/// System lines are *not* screened for mimicry: "Running → Paused" is the
|
||||
/// system label, not an imitation of one, and screening chrome would make the
|
||||
/// state indicator unrenderable. Cleaning still applies, because a policy rule
|
||||
/// or an error message can carry a path or a payload the runtime did not
|
||||
/// author.
|
||||
pub fn sanitize_system_text(raw: &str) -> SanitizedText {
|
||||
clean(raw, false)
|
||||
}
|
||||
|
||||
fn clean(raw: &str, screen_for_mimicry: bool) -> SanitizedText {
|
||||
let mut findings = Vec::new();
|
||||
|
||||
let stripped = strip_ansi(raw, &mut findings);
|
||||
let flattened = flatten_and_strip_controls(&stripped, &mut findings);
|
||||
let mut text = collapse_whitespace(&flattened);
|
||||
|
||||
if text.chars().count() > MAX_TASK_CHARS {
|
||||
text = text.chars().take(MAX_TASK_CHARS - 1).collect::<String>() + "…";
|
||||
findings.push(SanitizerFinding::Truncated);
|
||||
}
|
||||
|
||||
if screen_for_mimicry {
|
||||
if let Some(token) = system_label_hit(&text) {
|
||||
findings.push(SanitizerFinding::SystemLabelMimicry(token));
|
||||
return SanitizedText {
|
||||
display: WITHHELD_TEXT.to_string(),
|
||||
withheld: Some(text),
|
||||
suspicious: true,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
SanitizedText {
|
||||
display: text,
|
||||
withheld: None,
|
||||
suspicious: false,
|
||||
findings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove CSI and OSC escape sequences, plus any stray ESC.
|
||||
fn strip_ansi(raw: &str, findings: &mut Vec<SanitizerFinding>) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars().peekable();
|
||||
let mut removed = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\u{1b}' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
removed = true;
|
||||
match chars.peek() {
|
||||
// CSI: ESC [ params final-byte in 0x40..=0x7e.
|
||||
Some('[') => {
|
||||
chars.next();
|
||||
for c in chars.by_ref() {
|
||||
if ('\u{40}'..='\u{7e}').contains(&c) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// OSC: ESC ] ... BEL or ESC \.
|
||||
Some(']') => {
|
||||
chars.next();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{7}' {
|
||||
break;
|
||||
}
|
||||
if c == '\u{1b}' && chars.peek() == Some(&'\\') {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any other two-character escape.
|
||||
Some(_) => {
|
||||
chars.next();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
if removed {
|
||||
findings.push(SanitizerFinding::AnsiEscapesRemoved);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Newlines and tabs become spaces; every other control character and every
|
||||
/// bidi/zero-width formatting character is dropped.
|
||||
fn flatten_and_strip_controls(input: &str, findings: &mut Vec<SanitizerFinding>) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut flattened = false;
|
||||
let mut removed = false;
|
||||
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'\n' | '\r' | '\t' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => {
|
||||
flattened = true;
|
||||
out.push(' ');
|
||||
}
|
||||
// C0/C1 controls, zero-width and bidi-override formatting.
|
||||
c if c.is_control()
|
||||
|| matches!(c, '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}') =>
|
||||
{
|
||||
removed = true;
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
if flattened {
|
||||
findings.push(SanitizerFinding::LineBreaksFlattened);
|
||||
}
|
||||
if removed {
|
||||
findings.push(SanitizerFinding::ControlCharactersRemoved);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collapse_whitespace(input: &str) -> String {
|
||||
input.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// The token this text mimics, if any. Word-boundary matched so "unapproved
|
||||
/// invoices" is ordinary task text while "Approved" is not.
|
||||
fn system_label_hit(text: &str) -> Option<String> {
|
||||
if let Some(glyph) = text.chars().find(|c| SYSTEM_LABEL_GLYPHS.contains(c)) {
|
||||
return Some(glyph.to_string());
|
||||
}
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
SYSTEM_LABEL_TOKENS
|
||||
.iter()
|
||||
.find(|token| contains_word(&lowered, token))
|
||||
.map(|token| (*token).to_string())
|
||||
}
|
||||
|
||||
fn contains_word(haystack: &str, needle: &str) -> bool {
|
||||
let mut from = 0;
|
||||
while let Some(idx) = haystack[from..].find(needle) {
|
||||
let start = from + idx;
|
||||
let end = start + needle.len();
|
||||
let before_ok = start == 0
|
||||
|| !haystack[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|c| c.is_alphanumeric());
|
||||
let after_ok = end == haystack.len()
|
||||
|| !haystack[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_alphanumeric());
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
from = start + 1;
|
||||
if from >= haystack.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@
|
|||
//! - [`capability`] — derive the install-time capability contract (requirements P6).
|
||||
//! - [`runtime`] — apply the FR004 runtime selection order (ADR-289 §2).
|
||||
//! - [`state`] — encrypted state-capsule layout (ADR-288).
|
||||
//! - [`dock`], [`dock_state`], [`dock_roster`], [`dock_events`] — the Agent
|
||||
//! Dock: what a running agent shows and how it is stopped (ADR-295).
|
||||
//!
|
||||
//! # Security invariants
|
||||
//!
|
||||
|
|
@ -30,8 +32,19 @@
|
|||
//! vendored compatibility matrix. No environment variable, CLI flag, or
|
||||
//! setting reorders it; only signed policy may, and signed policy is not yet
|
||||
//! implemented (ADR-289 §2).
|
||||
//! 7. **Agent input cannot reach dock chrome.** An agent supplies task text and
|
||||
//! progress and nothing else, through
|
||||
//! [`dock::AgentProvidedStatus::from_agent`]. Trust badge, network
|
||||
//! indicator, permission summary, witness status, resource usage, cost, and
|
||||
//! the runtime state live in [`dock::SystemOwnedStatus`], which has no
|
||||
//! constructor or setter that takes an agent-derived value (ADR-295 §7).
|
||||
|
||||
pub mod capability;
|
||||
pub mod dock;
|
||||
pub mod dock_events;
|
||||
pub mod dock_roster;
|
||||
pub mod dock_state;
|
||||
pub mod dock_text;
|
||||
pub mod inspect;
|
||||
pub mod runtime;
|
||||
pub mod state;
|
||||
|
|
@ -44,10 +57,21 @@ pub mod commands;
|
|||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// Dock chrome state lives here, on the Rust side of the process
|
||||
// boundary, not in the webview that renders agent content.
|
||||
.manage(commands::DockSurface::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::inspect_rvf,
|
||||
commands::capability_card,
|
||||
commands::runtime_selection,
|
||||
commands::dock_view,
|
||||
commands::dock_expand,
|
||||
commands::dock_pause,
|
||||
commands::dock_terminate,
|
||||
commands::dock_swarm_view,
|
||||
commands::dock_notifications,
|
||||
commands::dock_agent_report,
|
||||
commands::dock_add_scaffold_agent,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running RVForge Reader");
|
||||
|
|
|
|||
218
crates/rvforge-reader/tests/dock_events.rs
Normal file
218
crates/rvforge-reader/tests/dock_events.rs
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
//! ADR-295 §9 / requirements D8: only five event kinds may interrupt.
|
||||
|
||||
use rvforge_reader::dock_events::{DockEvent, EventLog, Surfacing};
|
||||
use rvforge_reader::dock_state::DockState;
|
||||
|
||||
fn threshold_events() -> Vec<DockEvent> {
|
||||
vec![
|
||||
DockEvent::ApprovalRequested {
|
||||
agent_id: "a".into(),
|
||||
summary: "read ~/Documents/contracts".into(),
|
||||
},
|
||||
DockEvent::PolicyViolation {
|
||||
agent_id: "a".into(),
|
||||
rule: "network:deny".into(),
|
||||
},
|
||||
DockEvent::CostLimitReached {
|
||||
agent_id: "a".into(),
|
||||
spent_micros: 5_100_000,
|
||||
limit_micros: 5_000_000,
|
||||
},
|
||||
DockEvent::Failure {
|
||||
agent_id: "a".into(),
|
||||
message: "model load failed".into(),
|
||||
},
|
||||
DockEvent::Milestone {
|
||||
agent_id: "a".into(),
|
||||
description: "finished the first pass".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn routine_events() -> Vec<DockEvent> {
|
||||
vec![
|
||||
DockEvent::ProgressUpdated {
|
||||
agent_id: "a".into(),
|
||||
progress: 68,
|
||||
},
|
||||
DockEvent::TaskTextUpdated {
|
||||
agent_id: "a".into(),
|
||||
text: "Reviewing 42 documents".into(),
|
||||
},
|
||||
DockEvent::ToolCall {
|
||||
agent_id: "a".into(),
|
||||
tool: "grep".into(),
|
||||
},
|
||||
DockEvent::NetworkActivity {
|
||||
agent_id: "a".into(),
|
||||
peer: "127.0.0.1:8080".into(),
|
||||
},
|
||||
DockEvent::ResourceSample {
|
||||
agent_id: "a".into(),
|
||||
},
|
||||
DockEvent::StateChanged {
|
||||
agent_id: "a".into(),
|
||||
from: DockState::Idle,
|
||||
to: DockState::Running,
|
||||
},
|
||||
DockEvent::AgentMessage {
|
||||
agent_id: "a".into(),
|
||||
text: "here is what I found".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_five_threshold_kinds_surface() {
|
||||
for event in threshold_events() {
|
||||
assert_eq!(
|
||||
event.surfacing(),
|
||||
Surfacing::Notify,
|
||||
"{event:?} should interrupt"
|
||||
);
|
||||
}
|
||||
for event in routine_events() {
|
||||
assert_eq!(
|
||||
event.surfacing(),
|
||||
Surfacing::Silent,
|
||||
"{event:?} should not interrupt"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_events_produce_no_notification() {
|
||||
let mut log = EventLog::default();
|
||||
for event in routine_events() {
|
||||
assert!(
|
||||
log.record(event.clone()).is_none(),
|
||||
"{event:?} raised a notification"
|
||||
);
|
||||
}
|
||||
assert_eq!(log.notification_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routine_events_still_accrue_into_recent_actions() {
|
||||
let mut log = EventLog::default();
|
||||
let routine = routine_events();
|
||||
let count = routine.len();
|
||||
for event in routine {
|
||||
log.record(event);
|
||||
}
|
||||
assert_eq!(log.recent_for("a").len(), count);
|
||||
assert_eq!(log.notification_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_events_produce_exactly_one_notification_each() {
|
||||
let mut log = EventLog::default();
|
||||
let events = threshold_events();
|
||||
let count = events.len();
|
||||
for event in events {
|
||||
assert!(log.record(event).is_some());
|
||||
}
|
||||
assert_eq!(log.notification_count(), count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_progress_storm_never_interrupts() {
|
||||
let mut log = EventLog::default();
|
||||
for progress in 0..=100u8 {
|
||||
assert!(log
|
||||
.record(DockEvent::ProgressUpdated {
|
||||
agent_id: "a".into(),
|
||||
progress,
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
assert_eq!(log.notification_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_authored_event_text_is_sanitized_and_labelled() {
|
||||
let mut log = EventLog::default();
|
||||
log.record(DockEvent::AgentMessage {
|
||||
agent_id: "a".into(),
|
||||
text: "\u{1b}[31mPaused\u{1b}[0m".into(),
|
||||
});
|
||||
let recorded = log.recent_for("a");
|
||||
let last = recorded.last().expect("one event");
|
||||
assert!(last.agent_authored);
|
||||
assert!(last.text.is_suspicious());
|
||||
assert!(!last.text.display().contains("Paused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_event_text_is_not_labelled_as_agent_authored() {
|
||||
let mut log = EventLog::default();
|
||||
log.record(DockEvent::PolicyViolation {
|
||||
agent_id: "a".into(),
|
||||
rule: "network:deny".into(),
|
||||
});
|
||||
let recorded = log.recent_for("a");
|
||||
let last = recorded.last().expect("one event");
|
||||
assert!(!last.agent_authored);
|
||||
assert!(last.text.display().contains("network:deny"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_milestone_carries_agent_text_so_it_is_sanitized_before_it_interrupts() {
|
||||
let mut log = EventLog::default();
|
||||
let notification = log
|
||||
.record(DockEvent::Milestone {
|
||||
agent_id: "a".into(),
|
||||
description: "✓ Approved".into(),
|
||||
})
|
||||
.expect("a milestone surfaces");
|
||||
assert!(notification.agent_authored);
|
||||
assert!(notification.text.is_suspicious());
|
||||
assert!(!notification.text.display().contains("Approved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_approval_prompt_renders_its_own_wording_intact() {
|
||||
// The prompt is drawn from the policy engine, so words that would be
|
||||
// withheld in agent text are the correct chrome here.
|
||||
let mut log = EventLog::default();
|
||||
let notification = log
|
||||
.record(DockEvent::ApprovalRequested {
|
||||
agent_id: "a".into(),
|
||||
summary: "Approve network access to api.example.com".into(),
|
||||
})
|
||||
.expect("an approval surfaces");
|
||||
assert!(!notification.agent_authored);
|
||||
assert!(!notification.text.is_suspicious());
|
||||
assert_eq!(
|
||||
notification.text.display(),
|
||||
"Approve network access to api.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_is_bounded() {
|
||||
let mut log = EventLog::with_capacity(10);
|
||||
for progress in 0..50u8 {
|
||||
log.record(DockEvent::ProgressUpdated {
|
||||
agent_id: "a".into(),
|
||||
progress,
|
||||
});
|
||||
}
|
||||
assert_eq!(log.recent().count(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_actions_are_scoped_to_one_agent() {
|
||||
let mut log = EventLog::default();
|
||||
log.record(DockEvent::ToolCall {
|
||||
agent_id: "a".into(),
|
||||
tool: "grep".into(),
|
||||
});
|
||||
log.record(DockEvent::ToolCall {
|
||||
agent_id: "b".into(),
|
||||
tool: "find".into(),
|
||||
});
|
||||
assert_eq!(log.recent_for("a").len(), 1);
|
||||
assert_eq!(log.recent_for("b").len(), 1);
|
||||
assert_eq!(log.recent_for("c").len(), 0);
|
||||
}
|
||||
357
crates/rvforge-reader/tests/dock_roster.rs
Normal file
357
crates/rvforge-reader/tests/dock_roster.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! ADR-295 §5 (multi-agent policy) and the §Acceptance-criteria control path.
|
||||
|
||||
use rvforge_reader::dock::{
|
||||
AgentDockEntry, IsolationClass, NetworkIndicator, PermissionSummary, ResourceUsage,
|
||||
SystemOwnedStatus, TrustBadge, WitnessStatus,
|
||||
};
|
||||
use rvforge_reader::dock_roster::{
|
||||
DockInteraction, DockRoster, InteractionOutcome, AGENT_CONTENT_LABEL, SECONDARY_SLOTS,
|
||||
};
|
||||
use rvforge_reader::dock_state::{Actor, DockControl, DockState};
|
||||
|
||||
fn system(state: DockState) -> SystemOwnedStatus {
|
||||
SystemOwnedStatus::measured(
|
||||
state,
|
||||
TrustBadge::VerifiedPublisher,
|
||||
NetworkIndicator::Disabled,
|
||||
PermissionSummary {
|
||||
isolation: IsolationClass::Wasm,
|
||||
local_model: true,
|
||||
network_allowed: false,
|
||||
selected_folder_access: true,
|
||||
encrypted_memory: true,
|
||||
grants: vec!["Read the files and folders you select".to_string()],
|
||||
pending_approvals: vec!["Read ~/Documents/contracts".to_string()],
|
||||
},
|
||||
WitnessStatus::Valid,
|
||||
ResourceUsage {
|
||||
cpu_millis_per_sec: 100,
|
||||
memory_bytes: 100 * 1024 * 1024,
|
||||
tokens_in: 1_000,
|
||||
tokens_out: 200,
|
||||
},
|
||||
1_000_000,
|
||||
Some("local/llama".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn agent(id: &str, state: DockState) -> AgentDockEntry {
|
||||
AgentDockEntry::new(id, format!("Agent {id}"), "analyst", system(state))
|
||||
}
|
||||
|
||||
/// Insertion order deliberately does not match priority order.
|
||||
fn roster_of(states: &[(&str, DockState)]) -> DockRoster {
|
||||
let mut roster = DockRoster::new();
|
||||
for (id, state) in states {
|
||||
roster.insert(agent(id, *state));
|
||||
}
|
||||
roster
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_active_agent_is_the_one_needing_most_attention() {
|
||||
let roster = roster_of(&[
|
||||
("running", DockState::Running),
|
||||
("idle", DockState::Idle),
|
||||
("error", DockState::Error),
|
||||
("denied", DockState::CapabilityDenied),
|
||||
("waiting", DockState::WaitingForApproval),
|
||||
]);
|
||||
let view = roster.view();
|
||||
assert_eq!(view.active.as_ref().unwrap().agent_id, "waiting");
|
||||
let secondary: Vec<&str> = view.secondary.iter().map(|p| p.agent_id.as_str()).collect();
|
||||
assert_eq!(secondary, ["denied", "error"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_priority_order_is_waiting_denied_error_running_then_the_rest() {
|
||||
let roster = roster_of(&[
|
||||
("completed", DockState::Completed),
|
||||
("paused", DockState::Paused),
|
||||
("idle", DockState::Idle),
|
||||
("running", DockState::Running),
|
||||
("error", DockState::Error),
|
||||
("denied", DockState::CapabilityDenied),
|
||||
("waiting", DockState::WaitingForApproval),
|
||||
]);
|
||||
let order: Vec<&str> = roster
|
||||
.ordered()
|
||||
.iter()
|
||||
.map(|e| e.agent_id())
|
||||
.take(4)
|
||||
.collect();
|
||||
assert_eq!(order, ["waiting", "denied", "error", "running"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_break_on_the_most_recent_state_change_then_the_id() {
|
||||
let mut roster = roster_of(&[
|
||||
("aaa", DockState::Running),
|
||||
("bbb", DockState::Running),
|
||||
("ccc", DockState::Running),
|
||||
]);
|
||||
// Insertion order is the only signal so far; ccc arrived last.
|
||||
assert_eq!(roster.active().unwrap().agent_id(), "ccc");
|
||||
|
||||
// A newer state change moves aaa to the front of the same rank.
|
||||
roster
|
||||
.set_state("aaa", DockState::Idle, Actor::System)
|
||||
.unwrap();
|
||||
roster
|
||||
.set_state("aaa", DockState::Running, Actor::System)
|
||||
.unwrap();
|
||||
assert_eq!(roster.active().unwrap().agent_id(), "aaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_is_deterministic_across_repeated_reads() {
|
||||
let roster = roster_of(&[
|
||||
("z", DockState::Running),
|
||||
("y", DockState::Running),
|
||||
("x", DockState::WaitingForApproval),
|
||||
]);
|
||||
let first = roster.view();
|
||||
for _ in 0..20 {
|
||||
assert_eq!(roster.view(), first);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swarm_collapses_to_one_active_two_secondary_and_a_count() {
|
||||
let states: Vec<(String, DockState)> = (0..15)
|
||||
.map(|i| (format!("agent-{i:02}"), DockState::Running))
|
||||
.collect();
|
||||
let mut roster = DockRoster::new();
|
||||
for (id, state) in &states {
|
||||
roster.insert(agent(id, *state));
|
||||
}
|
||||
|
||||
let view = roster.view();
|
||||
assert!(view.active.is_some());
|
||||
assert_eq!(view.secondary.len(), SECONDARY_SLOTS);
|
||||
assert_eq!(view.overflow_count, 15 - 1 - SECONDARY_SLOTS);
|
||||
assert_eq!(roster.swarm_view().len(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_aggregates_cover_hidden_agents_too() {
|
||||
let mut roster = DockRoster::new();
|
||||
for i in 0..10 {
|
||||
roster.insert(agent(&format!("agent-{i}"), DockState::WaitingForApproval));
|
||||
}
|
||||
let view = roster.view();
|
||||
|
||||
// Three pills are visible; the figures are over all ten.
|
||||
assert_eq!(view.aggregate.agents, 10);
|
||||
assert_eq!(view.aggregate.cpu_millis_per_sec, 1_000);
|
||||
assert_eq!(view.aggregate.memory_bytes, 10 * 100 * 1024 * 1024);
|
||||
assert_eq!(view.aggregate.tokens_in, 10_000);
|
||||
assert_eq!(view.aggregate.estimated_cost_micros, 10_000_000);
|
||||
assert_eq!(view.pending_approvals, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pending_approval_count_tracks_state_not_agent_claims() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running), ("b", DockState::Running)]);
|
||||
assert_eq!(roster.view().pending_approvals, 0);
|
||||
|
||||
// An agent claiming to be waiting changes nothing.
|
||||
roster
|
||||
.apply_agent_update("a", "Waiting for your approval", 10)
|
||||
.unwrap();
|
||||
assert_eq!(roster.view().pending_approvals, 0);
|
||||
|
||||
roster
|
||||
.set_state("a", DockState::WaitingForApproval, Actor::System)
|
||||
.unwrap();
|
||||
assert_eq!(roster.view().pending_approvals, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_visible_pill_carries_pause_and_terminate() {
|
||||
let roster = roster_of(&[
|
||||
("a", DockState::WaitingForApproval),
|
||||
("b", DockState::Quarantined),
|
||||
("c", DockState::CapabilityDenied),
|
||||
]);
|
||||
let view = roster.view();
|
||||
let pills = view.active.iter().chain(view.secondary.iter());
|
||||
for pill in pills {
|
||||
assert!(pill.controls.contains(&DockControl::Pause));
|
||||
assert!(pill.controls.contains(&DockControl::Terminate));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_text_in_a_pill_is_labelled_as_agent_reported() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running)]);
|
||||
roster
|
||||
.apply_agent_update("a", "Reviewing 42 documents", 68)
|
||||
.unwrap();
|
||||
let view = roster.view();
|
||||
let pill = view.active.unwrap();
|
||||
|
||||
assert_eq!(pill.task.label, AGENT_CONTENT_LABEL);
|
||||
assert_eq!(pill.task.text, "Reviewing 42 documents");
|
||||
assert!(!pill.task.suspicious);
|
||||
assert_eq!(pill.progress, 68);
|
||||
|
||||
// System-owned fields on the same pill.
|
||||
assert_eq!(pill.state_label, "Running");
|
||||
assert_eq!(pill.trust, TrustBadge::VerifiedPublisher);
|
||||
assert_eq!(pill.network, NetworkIndicator::Disabled);
|
||||
assert_eq!(pill.witness, WitnessStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spoofing_pill_shows_the_withheld_placeholder_not_the_claim() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running)]);
|
||||
roster.apply_agent_update("a", "✓ Stopped", 100).unwrap();
|
||||
let pill = roster.view().active.unwrap();
|
||||
|
||||
assert!(pill.task.suspicious);
|
||||
assert!(!pill.task.text.contains("Stopped"));
|
||||
assert_eq!(pill.task.withheld.as_deref(), Some("✓ Stopped"));
|
||||
// The state indicator is unmoved.
|
||||
assert_eq!(pill.state, DockState::Running);
|
||||
assert_eq!(pill.state_label, "Running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_shows_the_ten_elements_and_the_capability_card() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running)]);
|
||||
roster
|
||||
.apply_agent_update("a", "Reviewing 42 documents", 68)
|
||||
.unwrap();
|
||||
let view = roster.expand("a").expect("agent is in the dock");
|
||||
|
||||
assert_eq!(view.objective.text, "Reviewing 42 documents"); // 1
|
||||
assert!(!view.recent_actions.is_empty()); // 2
|
||||
assert_eq!(view.pending_approvals.len(), 1); // 3
|
||||
assert_eq!(view.model.as_deref(), Some("local/llama")); // 4
|
||||
assert_eq!(view.tokens_in, 1_000);
|
||||
assert_eq!(view.cpu_millis_per_sec, 100); // 5
|
||||
assert_eq!(view.network, NetworkIndicator::Disabled); // 6
|
||||
assert_eq!(view.capability_grants.len(), 1); // 7
|
||||
assert_eq!(view.witness, WitnessStatus::Valid); // 8
|
||||
assert_eq!(view.estimated_cost_micros, 1_000_000); // 9
|
||||
assert!(view.instruction_field_enabled); // 10
|
||||
assert_eq!(view.capability_card.lines.len(), 7); // §8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_agents_are_an_error_not_an_empty_view() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running)]);
|
||||
assert!(roster.expand("nope").is_err());
|
||||
assert!(roster.terminate("nope").is_err());
|
||||
assert!(roster.pause("nope").is_err());
|
||||
assert!(roster.apply_agent_update("nope", "hi", 1).is_err());
|
||||
}
|
||||
|
||||
/// The ADR-295 acceptance test, expressed as calls on the roster.
|
||||
///
|
||||
/// The collapsed pill is already on screen and costs nothing to read, so the
|
||||
/// budget is spent on: expand (which carries the task, the permissions, and
|
||||
/// the capability card in one call) and terminate.
|
||||
#[test]
|
||||
fn identify_read_inspect_and_terminate_within_two_interactions() {
|
||||
let mut roster = roster_of(&[
|
||||
("busy-1", DockState::Running),
|
||||
("busy-2", DockState::Running),
|
||||
("asking", DockState::WaitingForApproval),
|
||||
("idle-1", DockState::Idle),
|
||||
]);
|
||||
roster
|
||||
.apply_agent_update("asking", "Reviewing 42 documents", 68)
|
||||
.unwrap();
|
||||
|
||||
let mut interactions = 0;
|
||||
|
||||
// Zero interactions: the bar identifies the active agent and its task.
|
||||
let view = roster.view();
|
||||
let pill = view.active.clone().expect("an active agent");
|
||||
assert_eq!(pill.agent_id, "asking");
|
||||
assert_eq!(pill.task.text, "Reviewing 42 documents");
|
||||
assert_eq!(pill.state, DockState::WaitingForApproval);
|
||||
|
||||
// Interaction one: inspect permissions.
|
||||
interactions += 1;
|
||||
let InteractionOutcome::Expanded(expanded) = roster
|
||||
.perform(DockInteraction::Expand {
|
||||
agent_id: pill.agent_id.clone(),
|
||||
})
|
||||
.expect("expand succeeds")
|
||||
else {
|
||||
panic!("expand did not return an expanded view");
|
||||
};
|
||||
assert_eq!(expanded.capability_card.lines.len(), 7);
|
||||
assert_eq!(expanded.capability_grants.len(), 1);
|
||||
assert_eq!(expanded.pending_approvals.len(), 1);
|
||||
|
||||
// Interaction two: terminate. No confirmation chain.
|
||||
interactions += 1;
|
||||
let outcome = roster
|
||||
.perform(DockInteraction::Terminate {
|
||||
agent_id: pill.agent_id.clone(),
|
||||
})
|
||||
.expect("terminate succeeds");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InteractionOutcome::StateChanged {
|
||||
agent_id: "asking".to_string(),
|
||||
state: DockState::Completed,
|
||||
}
|
||||
);
|
||||
|
||||
assert!(interactions <= 2, "took {interactions} interactions");
|
||||
assert_eq!(roster.get("asking").unwrap().state(), DockState::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_needs_no_expand_first() {
|
||||
// The same budget from the collapsed pill alone: one interaction.
|
||||
let mut roster = roster_of(&[("a", DockState::Quarantined)]);
|
||||
let outcome = roster
|
||||
.perform(DockInteraction::Terminate {
|
||||
agent_id: "a".to_string(),
|
||||
})
|
||||
.expect("terminate from the collapsed pill");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
InteractionOutcome::StateChanged {
|
||||
agent_id: "a".to_string(),
|
||||
state: DockState::Completed,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overflow_count_opens_the_full_swarm_view() {
|
||||
let mut roster = DockRoster::new();
|
||||
for i in 0..8 {
|
||||
roster.insert(agent(&format!("agent-{i}"), DockState::Running));
|
||||
}
|
||||
assert_eq!(roster.view().overflow_count, 5);
|
||||
|
||||
let InteractionOutcome::SwarmView { pills } = roster
|
||||
.perform(DockInteraction::OpenSwarmView)
|
||||
.expect("swarm view opens")
|
||||
else {
|
||||
panic!("wrong outcome");
|
||||
};
|
||||
assert_eq!(pills.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_change_through_the_roster_is_recorded_but_does_not_interrupt() {
|
||||
let mut roster = roster_of(&[("a", DockState::Running)]);
|
||||
let before = roster.event_log().notification_count();
|
||||
roster.pause("a").unwrap();
|
||||
assert_eq!(roster.get("a").unwrap().state(), DockState::Paused);
|
||||
assert_eq!(roster.event_log().notification_count(), before);
|
||||
assert!(roster
|
||||
.event_log()
|
||||
.recent_for("a")
|
||||
.iter()
|
||||
.any(|e| e.text.display().contains("Paused")));
|
||||
}
|
||||
216
crates/rvforge-reader/tests/dock_state_machine.rs
Normal file
216
crates/rvforge-reader/tests/dock_state_machine.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
//! ADR-295 §3 and §4: which state changes are legal, and who may make them.
|
||||
|
||||
use rvforge_reader::dock_state::{
|
||||
pause, terminate, transition, Actor, DockControl, DockState, TransitionError, DOCK_STATES,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn there_are_exactly_eight_states_and_each_is_visually_distinct() {
|
||||
assert_eq!(DOCK_STATES.len(), 8);
|
||||
|
||||
let mut colors: Vec<&str> = DOCK_STATES.iter().map(|s| s.color_token()).collect();
|
||||
colors.sort_unstable();
|
||||
colors.dedup();
|
||||
assert_eq!(colors.len(), 8, "two states share a color token");
|
||||
|
||||
let mut glyphs: Vec<&str> = DOCK_STATES.iter().map(|s| s.glyph()).collect();
|
||||
glyphs.sort_unstable();
|
||||
glyphs.dedup();
|
||||
assert_eq!(glyphs.len(), 8, "two states share a glyph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_denied_and_quarantined_are_not_error_subtypes() {
|
||||
assert_ne!(DockState::CapabilityDenied, DockState::Error);
|
||||
assert_ne!(DockState::Quarantined, DockState::Error);
|
||||
assert_ne!(
|
||||
DockState::CapabilityDenied.color_token(),
|
||||
DockState::Error.color_token()
|
||||
);
|
||||
assert_ne!(
|
||||
DockState::Quarantined.color_token(),
|
||||
DockState::Error.color_token()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_transitions_are_accepted() {
|
||||
for (from, to) in [
|
||||
(DockState::Idle, DockState::Running),
|
||||
(DockState::Running, DockState::WaitingForApproval),
|
||||
(DockState::WaitingForApproval, DockState::Running),
|
||||
(DockState::Running, DockState::CapabilityDenied),
|
||||
(DockState::CapabilityDenied, DockState::Running),
|
||||
(DockState::Paused, DockState::Running),
|
||||
(DockState::Error, DockState::Running),
|
||||
(DockState::Quarantined, DockState::Idle),
|
||||
(DockState::Running, DockState::Completed),
|
||||
] {
|
||||
assert_eq!(
|
||||
transition(from, to, Actor::System),
|
||||
Ok(to),
|
||||
"{from:?} -> {to:?} should be legal for the system"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_transitions_are_rejected() {
|
||||
for (from, to) in [
|
||||
// A capability denial implies a request, which implies execution.
|
||||
(DockState::Idle, DockState::CapabilityDenied),
|
||||
(DockState::Paused, DockState::CapabilityDenied),
|
||||
// Only a running agent can ask for approval.
|
||||
(DockState::Idle, DockState::WaitingForApproval),
|
||||
(DockState::Paused, DockState::WaitingForApproval),
|
||||
// Quarantine releases to Idle or Completed, never straight back to work.
|
||||
(DockState::Quarantined, DockState::Running),
|
||||
(DockState::Quarantined, DockState::Paused),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
transition(from, to, Actor::System),
|
||||
Err(TransitionError::Illegal { .. })
|
||||
),
|
||||
"{from:?} -> {to:?} should be illegal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_is_terminal_for_every_actor() {
|
||||
for actor in [Actor::System, Actor::User, Actor::Agent] {
|
||||
for to in DOCK_STATES {
|
||||
assert!(
|
||||
matches!(
|
||||
transition(DockState::Completed, to, actor),
|
||||
Err(TransitionError::Terminal { .. })
|
||||
),
|
||||
"{actor:?} moved a completed agent to {to:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(matches!(
|
||||
pause(DockState::Completed),
|
||||
Err(TransitionError::Terminal { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
terminate(DockState::Completed),
|
||||
Err(TransitionError::Terminal { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_cannot_claim_it_is_paused() {
|
||||
// The exact spoof of ADR-295 §Context: rendering "Paused" while executing.
|
||||
assert!(matches!(
|
||||
transition(DockState::Running, DockState::Paused, Actor::Agent),
|
||||
Err(TransitionError::AgentCannotEnter {
|
||||
to: DockState::Paused
|
||||
})
|
||||
));
|
||||
// The user and the runtime can.
|
||||
assert_eq!(
|
||||
transition(DockState::Running, DockState::Paused, Actor::User),
|
||||
Ok(DockState::Paused)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_cannot_put_itself_in_a_system_forced_state() {
|
||||
for to in [
|
||||
DockState::Paused,
|
||||
DockState::CapabilityDenied,
|
||||
DockState::Quarantined,
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
transition(DockState::Running, to, Actor::Agent),
|
||||
Err(TransitionError::AgentCannotEnter { .. })
|
||||
),
|
||||
"an agent asserted {to:?}"
|
||||
);
|
||||
assert!(to.is_system_forced());
|
||||
assert!(!to.is_agent_assertable());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_cannot_exit_quarantine() {
|
||||
for to in DOCK_STATES {
|
||||
assert!(
|
||||
matches!(
|
||||
transition(DockState::Quarantined, to, Actor::Agent),
|
||||
Err(TransitionError::AgentCannotLeave {
|
||||
from: DockState::Quarantined
|
||||
})
|
||||
),
|
||||
"an agent left quarantine for {to:?}"
|
||||
);
|
||||
}
|
||||
// The runtime can release it.
|
||||
assert_eq!(
|
||||
transition(DockState::Quarantined, DockState::Idle, Actor::System),
|
||||
Ok(DockState::Idle)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_cannot_exit_a_capability_denial_or_a_pause_by_itself() {
|
||||
for from in [DockState::CapabilityDenied, DockState::Paused] {
|
||||
assert!(matches!(
|
||||
transition(from, DockState::Running, Actor::Agent),
|
||||
Err(TransitionError::AgentCannotLeave { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
transition(from, DockState::Running, Actor::User),
|
||||
Ok(DockState::Running)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_terminate_are_legal_from_every_non_terminal_state() {
|
||||
for from in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) {
|
||||
assert_eq!(pause(from), Ok(DockState::Paused), "cannot pause {from:?}");
|
||||
assert_eq!(
|
||||
terminate(from),
|
||||
Ok(DockState::Completed),
|
||||
"cannot terminate {from:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_terminate_are_one_action_in_every_non_terminal_state() {
|
||||
for state in DOCK_STATES.iter().copied().filter(|s| !s.is_terminal()) {
|
||||
let controls = state.controls();
|
||||
assert!(
|
||||
controls.contains(&DockControl::Pause),
|
||||
"{state:?} hides Pause"
|
||||
);
|
||||
assert!(
|
||||
controls.contains(&DockControl::Terminate),
|
||||
"{state:?} hides Terminate"
|
||||
);
|
||||
assert!(
|
||||
controls.contains(&DockControl::Expand),
|
||||
"{state:?} hides Expand"
|
||||
);
|
||||
}
|
||||
assert!(DockState::Paused.controls().contains(&DockControl::Resume));
|
||||
assert_eq!(DockState::Completed.controls(), vec![DockControl::Expand]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pausing_an_already_paused_agent_succeeds() {
|
||||
// A control that sometimes fails is a control users stop reaching for.
|
||||
assert_eq!(pause(DockState::Paused), Ok(DockState::Paused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_state_transitions_to_itself() {
|
||||
for state in DOCK_STATES {
|
||||
assert!(!state.may_precede(state), "{state:?} precedes itself");
|
||||
}
|
||||
}
|
||||
232
crates/rvforge-reader/tests/dock_trust_boundary.rs
Normal file
232
crates/rvforge-reader/tests/dock_trust_boundary.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
//! ADR-295 §7: agent content cannot become dock chrome.
|
||||
//!
|
||||
//! The structural half of this boundary is enforced by the type signatures and
|
||||
//! is checked by the compiler rather than by a test:
|
||||
//!
|
||||
//! - [`AgentProvidedStatus`] has one constructor, `from_agent(&str, i64)`. It
|
||||
//! derives `Serialize` but not `Deserialize`, so no agent-supplied JSON
|
||||
//! object is ever deserialized into it — the agent's bytes only ever arrive
|
||||
//! as those two scalars.
|
||||
//! - [`SystemOwnedStatus`] has private fields, no `Deserialize`, and one
|
||||
//! constructor, `measured(..)`, whose arguments are all system types. There
|
||||
//! is no function anywhere that takes an `AgentProvidedStatus` (or a `&str`
|
||||
//! originating from one) and returns or mutates a `SystemOwnedStatus`.
|
||||
//!
|
||||
//! What is left to test at runtime is the sanitizer and the invariant that the
|
||||
//! one agent-facing mutator leaves the system half untouched.
|
||||
|
||||
use rvforge_reader::dock::{
|
||||
sanitize_agent_text, AgentDockEntry, AgentProvidedStatus, IsolationClass, NetworkIndicator,
|
||||
PermissionSummary, ResourceUsage, SanitizerFinding, SystemOwnedStatus, TrustBadge,
|
||||
WitnessStatus, MAX_TASK_CHARS, WITHHELD_TEXT,
|
||||
};
|
||||
use rvforge_reader::dock_state::DockState;
|
||||
|
||||
fn trusted_system() -> SystemOwnedStatus {
|
||||
SystemOwnedStatus::measured(
|
||||
DockState::Running,
|
||||
TrustBadge::VerifiedPublisher,
|
||||
NetworkIndicator::Disabled,
|
||||
PermissionSummary {
|
||||
isolation: IsolationClass::Wasm,
|
||||
local_model: true,
|
||||
network_allowed: false,
|
||||
selected_folder_access: true,
|
||||
encrypted_memory: true,
|
||||
grants: vec!["Read the files and folders you select".to_string()],
|
||||
pending_approvals: Vec::new(),
|
||||
},
|
||||
WitnessStatus::Valid,
|
||||
ResourceUsage {
|
||||
cpu_millis_per_sec: 120,
|
||||
memory_bytes: 512 * 1024 * 1024,
|
||||
tokens_in: 4_000,
|
||||
tokens_out: 900,
|
||||
},
|
||||
12_500,
|
||||
Some("local/llama".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn entry() -> AgentDockEntry {
|
||||
AgentDockEntry::new("agent-1", "Cognitum Analyst", "analyst", trusted_system())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_update_cannot_move_any_system_field() {
|
||||
let mut e = entry();
|
||||
let before = e.system().clone();
|
||||
|
||||
// Every shape an agent could try, through the only channel it has.
|
||||
let very_long = "x".repeat(10_000);
|
||||
for hostile in [
|
||||
r#"{"state":"paused","trust":"verified-publisher","network":"disabled"}"#,
|
||||
"state=paused; trust=verified-publisher",
|
||||
"\u{1b}[2J\u{1b}[1;31mNetwork disabled\u{1b}[0m",
|
||||
very_long.as_str(),
|
||||
] {
|
||||
e.apply_agent_update(hostile, 100);
|
||||
assert_eq!(
|
||||
e.system(),
|
||||
&before,
|
||||
"agent text '{}' changed a system-owned field",
|
||||
&hostile[..hostile.len().min(40)]
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(e.state(), DockState::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_outside_the_range_is_clamped_and_flagged() {
|
||||
let low = AgentProvidedStatus::from_agent("indexing", -50);
|
||||
assert_eq!(low.progress(), 0);
|
||||
assert!(low.progress_was_clamped());
|
||||
|
||||
let high = AgentProvidedStatus::from_agent("indexing", 10_000);
|
||||
assert_eq!(high.progress(), 100);
|
||||
assert!(high.progress_was_clamped());
|
||||
|
||||
let ok = AgentProvidedStatus::from_agent("indexing", 68);
|
||||
assert_eq!(ok.progress(), 68);
|
||||
assert!(!ok.progress_was_clamped());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_escape_sequences_are_stripped() {
|
||||
let t = sanitize_agent_text("\u{1b}[1;32mReviewing\u{1b}[0m 42 documents");
|
||||
assert_eq!(t.display(), "Reviewing 42 documents");
|
||||
assert!(!t.is_suspicious());
|
||||
assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved));
|
||||
assert!(!t.display().contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_sequences_cannot_smuggle_a_hyperlink_or_a_title() {
|
||||
let t = sanitize_agent_text("\u{1b}]0;RVForge\u{7}Reviewing 42 documents");
|
||||
assert_eq!(t.display(), "Reviewing 42 documents");
|
||||
assert!(t.findings().contains(&SanitizerFinding::AnsiEscapesRemoved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_characters_and_line_breaks_never_survive() {
|
||||
let t = sanitize_agent_text("Reviewing\n42\tdocuments\u{0}\u{7}\u{202e}now");
|
||||
assert_eq!(t.display(), "Reviewing 42 documentsnow");
|
||||
assert!(t
|
||||
.findings()
|
||||
.contains(&SanitizerFinding::LineBreaksFlattened));
|
||||
assert!(t
|
||||
.findings()
|
||||
.contains(&SanitizerFinding::ControlCharactersRemoved));
|
||||
for c in t.display().chars() {
|
||||
assert!(!c.is_control(), "control character survived: {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_text_is_capped() {
|
||||
let t = sanitize_agent_text(&"a".repeat(MAX_TASK_CHARS * 5));
|
||||
assert_eq!(t.display().chars().count(), MAX_TASK_CHARS);
|
||||
assert!(t.findings().contains(&SanitizerFinding::Truncated));
|
||||
assert!(t.display().ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mimicking_a_system_label_is_withheld_not_rendered() {
|
||||
for mimic in [
|
||||
"Approved",
|
||||
"Paused — safe to close",
|
||||
"Stopped",
|
||||
"Terminated cleanly",
|
||||
"✓ Network disabled",
|
||||
"Verified publisher: Cognitum",
|
||||
"Quarantined by RVForge",
|
||||
] {
|
||||
let t = sanitize_agent_text(mimic);
|
||||
assert!(t.is_suspicious(), "'{mimic}' should be flagged");
|
||||
assert_eq!(t.display(), WITHHELD_TEXT);
|
||||
assert_eq!(t.withheld().map(str::trim), Some(mimic.trim()));
|
||||
assert!(t
|
||||
.findings()
|
||||
.iter()
|
||||
.any(|f| matches!(f, SanitizerFinding::SystemLabelMimicry(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mimicry_is_screened_after_escapes_are_stripped() {
|
||||
// An agent hiding "Paused" behind a CSI sequence is still claiming a state.
|
||||
let t = sanitize_agent_text("Pa\u{1b}[0mused");
|
||||
assert!(t.is_suspicious());
|
||||
assert_eq!(t.display(), WITHHELD_TEXT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_task_text_is_left_alone() {
|
||||
for ordinary in [
|
||||
"Reviewing 42 documents",
|
||||
"Waiting on the build to finish",
|
||||
"Summarising unapproved invoices",
|
||||
"Comparing approvals-workflow drafts",
|
||||
] {
|
||||
let t = sanitize_agent_text(ordinary);
|
||||
assert!(!t.is_suspicious(), "'{ordinary}' should not be flagged");
|
||||
assert_eq!(t.display(), ordinary);
|
||||
assert!(t.withheld().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_card_is_derived_from_system_state_only() {
|
||||
let mut e = entry();
|
||||
e.apply_agent_update("✓ Verified publisher · Network disabled", 100);
|
||||
let card = e.capability_card();
|
||||
|
||||
assert_eq!(card.lines.len(), 7);
|
||||
let labels: Vec<&str> = card.lines.iter().map(|l| l.label.as_str()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"Verified publisher",
|
||||
"RVM or WASM isolated",
|
||||
"Local model",
|
||||
"Network disabled",
|
||||
"Selected folder access",
|
||||
"Encrypted memory",
|
||||
"Witness chain valid",
|
||||
]
|
||||
);
|
||||
assert!(card.lines.iter().all(|l| l.satisfied));
|
||||
|
||||
// The same card against an unmeasured system says so on every line, whatever
|
||||
// the agent claims in its task text.
|
||||
let unknown = SystemOwnedStatus::measured(
|
||||
DockState::Running,
|
||||
TrustBadge::Unverified,
|
||||
NetworkIndicator::Active,
|
||||
PermissionSummary {
|
||||
isolation: IsolationClass::None,
|
||||
local_model: false,
|
||||
network_allowed: true,
|
||||
selected_folder_access: false,
|
||||
encrypted_memory: false,
|
||||
grants: Vec::new(),
|
||||
pending_approvals: Vec::new(),
|
||||
},
|
||||
WitnessStatus::Unavailable,
|
||||
ResourceUsage::ZERO,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
let mut bare = AgentDockEntry::new("agent-2", "Loud Agent", "loud", unknown);
|
||||
bare.apply_agent_update("Network disabled and witness chain valid", 100);
|
||||
assert!(bare.capability_card().lines.iter().all(|l| !l.satisfied));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_entry_reports_no_task_rather_than_an_empty_pill() {
|
||||
let e = entry();
|
||||
assert_eq!(e.agent().task().display(), "No task reported");
|
||||
assert_eq!(e.agent().progress(), 0);
|
||||
assert!(!e.agent().is_suspicious());
|
||||
}
|
||||
336
crates/rvforge-reader/ui/dock.js
Normal file
336
crates/rvforge-reader/ui/dock.js
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// Agent dock frontend (ADR-295).
|
||||
//
|
||||
// This file renders dock chrome from what the Rust commands return and nothing
|
||||
// else. Two rules hold everywhere below:
|
||||
//
|
||||
// 1. Agent-supplied strings reach the DOM only through `textContent`, only
|
||||
// inside an element carrying `.agent-region`, and only next to the
|
||||
// dock-drawn "Agent-reported" label. There is no path from agent text to
|
||||
// markup, to a class name, or to a state indicator.
|
||||
// 2. Nothing here decides a state, a badge, or a permission. Pause and
|
||||
// terminate call the Rust commands and re-read the roster; the answer is
|
||||
// whatever Rust says it is.
|
||||
//
|
||||
// Wrapped in an IIFE so its bindings do not collide with main.js, which shares
|
||||
// the classic-script global scope.
|
||||
|
||||
(function () {
|
||||
const invoke = (cmd, args) => {
|
||||
const api = window.__TAURI__;
|
||||
if (!api) {
|
||||
return Promise.reject(
|
||||
new Error("Tauri API unavailable (open this page through the app)."),
|
||||
);
|
||||
}
|
||||
return api.core.invoke(cmd, args);
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const dock = { expandedAgent: null };
|
||||
|
||||
function fail(err) {
|
||||
const box = $("error");
|
||||
box.textContent = String(err);
|
||||
box.hidden = false;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
$("error").hidden = true;
|
||||
}
|
||||
|
||||
function bytes(n) {
|
||||
if (!n) return "0 B";
|
||||
const units = ["B", "KiB", "MiB", "GiB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
const money = (micros) => `$${(micros / 1_000_000).toFixed(2)}`;
|
||||
|
||||
// Agent text goes here and nowhere else. `suspicious` is a Rust verdict; the
|
||||
// agent cannot set it, and when it is set the withheld original is shown as a
|
||||
// title so a curious user can still read what was claimed.
|
||||
function renderAgentContent(regionEl, textEl, content) {
|
||||
textEl.textContent = content.text;
|
||||
regionEl.classList.toggle("suspicious", content.suspicious);
|
||||
regionEl.title = content.suspicious
|
||||
? `Withheld — the agent said: ${content.withheld ?? ""}`
|
||||
: "";
|
||||
const label = regionEl.querySelector(".agent-label");
|
||||
if (label) label.textContent = content.label;
|
||||
}
|
||||
|
||||
function renderPill(pill) {
|
||||
$("dock-empty").hidden = true;
|
||||
$("dock-pill").hidden = false;
|
||||
|
||||
// System-owned: manifest identity and measured state.
|
||||
$("pill-icon").textContent = "◆";
|
||||
$("pill-name").textContent = pill.name;
|
||||
|
||||
renderAgentContent($("pill-task-region"), $("pill-task"), pill.task);
|
||||
|
||||
$("pill-progress-bar").value = pill.progress;
|
||||
$("pill-progress-text").textContent = `${pill.progress}%`;
|
||||
|
||||
const chip = $("pill-state");
|
||||
chip.className = `state-chip ${pill.state_color}`;
|
||||
$("pill-state-glyph").textContent = pill.state_glyph;
|
||||
$("pill-state-label").textContent = pill.state_label;
|
||||
|
||||
// Pause and Terminate are present in every non-terminal state; the list
|
||||
// comes from Rust, so the webview cannot hide a control.
|
||||
const controls = pill.controls;
|
||||
const pause = $("pill-pause");
|
||||
const resumable = controls.includes("resume");
|
||||
pause.textContent = resumable ? "Resume" : "Pause";
|
||||
pause.hidden = !(controls.includes("pause") || resumable);
|
||||
$("pill-stop").hidden = !controls.includes("terminate");
|
||||
$("pill-expand").hidden = !controls.includes("expand");
|
||||
|
||||
$("dock-pill").dataset.agentId = pill.agent_id;
|
||||
}
|
||||
|
||||
function renderSecondary(pills) {
|
||||
const box = $("dock-secondary");
|
||||
box.replaceChildren();
|
||||
for (const pill of pills) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "secondary-icon";
|
||||
btn.title = `${pill.name} — ${pill.state_label}`;
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `dot ${pill.state_color}`;
|
||||
const name = document.createElement("span");
|
||||
name.textContent = pill.name;
|
||||
|
||||
btn.append(dot, name);
|
||||
btn.addEventListener("click", () => expand(pill.agent_id));
|
||||
box.append(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderView(view) {
|
||||
if (view.active) {
|
||||
renderPill(view.active);
|
||||
} else {
|
||||
$("dock-pill").hidden = true;
|
||||
$("dock-empty").hidden = false;
|
||||
}
|
||||
renderSecondary(view.secondary);
|
||||
|
||||
const overflow = $("dock-overflow");
|
||||
overflow.hidden = view.overflow_count === 0;
|
||||
overflow.textContent = `+${view.overflow_count} more`;
|
||||
|
||||
// Aggregates cover every agent, including the ones with no slot.
|
||||
$("agg-cpu").textContent = `${view.aggregate.cpu_millis_per_sec} mCPU`;
|
||||
$("agg-mem").textContent = bytes(view.aggregate.memory_bytes);
|
||||
$("agg-cost").textContent = money(view.aggregate.estimated_cost_micros);
|
||||
$("agg-approvals").textContent =
|
||||
view.pending_approvals === 1
|
||||
? "1 approval"
|
||||
: `${view.pending_approvals} approvals`;
|
||||
}
|
||||
|
||||
function renderActions(actions) {
|
||||
const ul = $("exp-actions");
|
||||
ul.replaceChildren();
|
||||
for (const action of actions.slice(-12).reverse()) {
|
||||
const li = document.createElement("li");
|
||||
// Agent-authored lines keep the agent styling inside the list too.
|
||||
li.className = action.agent_authored ? "agent-authored" : "";
|
||||
li.textContent = action.text.display;
|
||||
if (action.text.suspicious && action.text.withheld) {
|
||||
li.title = `Withheld — the agent said: ${action.text.withheld}`;
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(id, items, emptyText) {
|
||||
const ul = $(id);
|
||||
ul.replaceChildren();
|
||||
if (!items.length) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = emptyText;
|
||||
ul.append(li);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = item;
|
||||
ul.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCapabilityCard(card) {
|
||||
const ul = $("exp-card");
|
||||
ul.replaceChildren();
|
||||
for (const line of card.lines) {
|
||||
const li = document.createElement("li");
|
||||
li.className = line.satisfied ? "yes" : "no";
|
||||
|
||||
const mark = document.createElement("span");
|
||||
mark.className = "mark";
|
||||
mark.textContent = line.satisfied ? "✓" : "✕";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = line.label;
|
||||
|
||||
const detail = document.createElement("span");
|
||||
detail.className = "detail";
|
||||
detail.textContent = line.detail;
|
||||
|
||||
li.append(mark, label, detail);
|
||||
ul.append(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderExpanded(view) {
|
||||
dock.expandedAgent = view.pill.agent_id;
|
||||
renderPill(view.pill);
|
||||
renderAgentContent(
|
||||
$("exp-objective").parentElement,
|
||||
$("exp-objective"),
|
||||
view.objective,
|
||||
);
|
||||
|
||||
renderActions(view.recent_actions);
|
||||
renderList("exp-approvals", view.pending_approvals, "None pending.");
|
||||
renderList("exp-grants", view.capability_grants, "Nothing is granted.");
|
||||
|
||||
$("exp-model").textContent = view.model ?? "no model attached";
|
||||
$("exp-tokens").textContent = `${view.tokens_in} in · ${view.tokens_out} out`;
|
||||
$("exp-cpu").textContent = `${view.cpu_millis_per_sec} mCPU`;
|
||||
$("exp-mem").textContent = bytes(view.memory_bytes);
|
||||
$("exp-network").textContent = view.network;
|
||||
$("exp-witness").textContent = view.witness;
|
||||
$("exp-witness").className =
|
||||
view.witness === "valid" ? "status-ok" : "status-unverified";
|
||||
$("exp-cost").textContent = money(view.estimated_cost_micros);
|
||||
|
||||
$("exp-instruction").disabled = !view.instruction_field_enabled;
|
||||
$("exp-send").disabled = !view.instruction_field_enabled;
|
||||
|
||||
renderCapabilityCard(view.capability_card);
|
||||
|
||||
$("dock-expanded").hidden = false;
|
||||
$("pill-expand").setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
renderView(await invoke("dock_view"));
|
||||
if (dock.expandedAgent) {
|
||||
renderExpanded(await invoke("dock_expand", { agentId: dock.expandedAgent }));
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function expand(agentId) {
|
||||
clearError();
|
||||
try {
|
||||
renderExpanded(await invoke("dock_expand", { agentId }));
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
function activeAgentId() {
|
||||
return $("dock-pill").dataset.agentId || null;
|
||||
}
|
||||
|
||||
// One action. No confirmation chain, no expand first.
|
||||
async function control(command) {
|
||||
const agentId = activeAgentId();
|
||||
if (!agentId) return;
|
||||
clearError();
|
||||
try {
|
||||
await invoke(command, { agentId });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
$("pill-pause").addEventListener("click", () => control("dock_pause"));
|
||||
$("pill-stop").addEventListener("click", () => control("dock_terminate"));
|
||||
$("pill-expand").addEventListener("click", () => {
|
||||
const panel = $("dock-expanded");
|
||||
if (!panel.hidden) {
|
||||
panel.hidden = true;
|
||||
dock.expandedAgent = null;
|
||||
$("pill-expand").setAttribute("aria-expanded", "false");
|
||||
return;
|
||||
}
|
||||
const agentId = activeAgentId();
|
||||
if (agentId) expand(agentId);
|
||||
});
|
||||
|
||||
$("dock-overflow").addEventListener("click", async () => {
|
||||
clearError();
|
||||
try {
|
||||
const pills = await invoke("dock_swarm_view");
|
||||
renderSecondary(pills.slice(1));
|
||||
$("dock-overflow").hidden = true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Development harness. `dock_add_scaffold_agent` reports unknown values for
|
||||
// every security-bearing field, and `dock_agent_report` is the agent channel:
|
||||
// task text and progress, both sanitized in Rust.
|
||||
|
||||
$("dock-add").addEventListener("click", async () => {
|
||||
const name = $("dock-new-name").value.trim() || "Unnamed agent";
|
||||
clearError();
|
||||
try {
|
||||
renderView(
|
||||
await invoke("dock_add_scaffold_agent", {
|
||||
agentId: `agent-${Date.now()}`,
|
||||
name,
|
||||
icon: "analyst",
|
||||
}),
|
||||
);
|
||||
$("dock-new-name").value = "";
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
$("dock-agent-send").addEventListener("click", async () => {
|
||||
const agentId = activeAgentId();
|
||||
if (!agentId) {
|
||||
fail("Register an agent first.");
|
||||
return;
|
||||
}
|
||||
const progress = Number.parseInt($("dock-agent-progress").value, 10);
|
||||
clearError();
|
||||
try {
|
||||
await invoke("dock_agent_report", {
|
||||
agentId,
|
||||
task: $("dock-agent-task").value,
|
||||
progress: Number.isNaN(progress) ? 0 : progress,
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
});
|
||||
|
||||
$("exp-send").addEventListener("click", () =>
|
||||
fail("Instructions reach the agent once rvm-ffi is wired in."),
|
||||
);
|
||||
|
||||
const dockStep = document.querySelector('.step[data-goto="dock"]');
|
||||
if (dockStep) dockStep.addEventListener("click", refresh);
|
||||
})();
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
<button class="step" data-goto="open" aria-current="true">1 · Open</button>
|
||||
<button class="step" data-goto="capability" disabled>2 · Capabilities</button>
|
||||
<button class="step" data-goto="runtime" disabled>3 · Runtime</button>
|
||||
<button class="step" data-goto="dock">4 · Dock</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
@ -112,6 +113,120 @@
|
|||
<button disabled>Rollback state</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Screen 4: the agent dock (ADR-295) -->
|
||||
<section id="screen-dock" class="screen" aria-labelledby="dock-title">
|
||||
<h2 id="dock-title">Agent dock</h2>
|
||||
<p class="lede">
|
||||
Everything with a security meaning here — state, trust badge, network
|
||||
indicator, permissions, witness status, cost — is drawn by RVForge
|
||||
from RVForge’s own state. Agents supply only the text and the
|
||||
percentage inside the marked “agent-reported” regions.
|
||||
</p>
|
||||
|
||||
<!-- Collapsed bar. System chrome: solid surface, square edges,
|
||||
system font. Agent content is confined to .agent-region. -->
|
||||
<div class="dock-bar" role="group" aria-label="Agent dock">
|
||||
<div id="dock-empty" class="dock-empty">No agent is running.</div>
|
||||
|
||||
<div id="dock-pill" class="pill" hidden>
|
||||
<span class="pill-icon" id="pill-icon" aria-hidden="true">◆</span>
|
||||
<span class="pill-name" id="pill-name">—</span>
|
||||
|
||||
<span class="agent-region" id="pill-task-region">
|
||||
<span class="agent-label" id="pill-task-label">Agent-reported</span>
|
||||
<span class="agent-text" id="pill-task">—</span>
|
||||
</span>
|
||||
|
||||
<span class="pill-progress">
|
||||
<progress id="pill-progress-bar" max="100" value="0"></progress>
|
||||
<span id="pill-progress-text" class="mono">0%</span>
|
||||
</span>
|
||||
|
||||
<span class="state-chip" id="pill-state">
|
||||
<span class="state-glyph" id="pill-state-glyph" aria-hidden="true"></span>
|
||||
<span id="pill-state-label">—</span>
|
||||
</span>
|
||||
|
||||
<button class="ctl" id="pill-pause">Pause</button>
|
||||
<button class="ctl stop" id="pill-stop">Stop</button>
|
||||
<button class="ctl" id="pill-expand" aria-expanded="false">Expand</button>
|
||||
</div>
|
||||
|
||||
<div class="pill-secondary" id="dock-secondary" aria-label="Other agents"></div>
|
||||
<button class="overflow" id="dock-overflow" hidden>+0 more</button>
|
||||
|
||||
<div class="dock-aggregate">
|
||||
<span title="Aggregate CPU across every agent, hidden ones included"
|
||||
id="agg-cpu" class="mono">0 mCPU</span>
|
||||
<span title="Aggregate memory across every agent" id="agg-mem" class="mono">0 B</span>
|
||||
<span title="Estimated cost across every agent" id="agg-cost" class="mono">$0.00</span>
|
||||
<span id="agg-approvals" class="approvals">0 approvals</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded panel: the ten ADR-295 §2 elements + the §8 card. -->
|
||||
<div id="dock-expanded" class="panel" hidden>
|
||||
<div class="expanded-grid">
|
||||
<div>
|
||||
<h3>Objective</h3>
|
||||
<p class="agent-region block">
|
||||
<span class="agent-label">Agent-reported</span>
|
||||
<span class="agent-text" id="exp-objective">—</span>
|
||||
</p>
|
||||
|
||||
<h3>Recent actions</h3>
|
||||
<ul id="exp-actions" class="actions"></ul>
|
||||
|
||||
<h3>Pending approvals</h3>
|
||||
<ul id="exp-approvals" class="notes"></ul>
|
||||
|
||||
<h3>Instruction</h3>
|
||||
<div class="row">
|
||||
<input id="exp-instruction" type="text"
|
||||
placeholder="Send an instruction to this agent" />
|
||||
<button id="exp-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Measured by RVForge</h3>
|
||||
<dl class="facts compact">
|
||||
<dt>Model</dt><dd id="exp-model">—</dd>
|
||||
<dt>Tokens</dt><dd id="exp-tokens">—</dd>
|
||||
<dt>CPU</dt><dd id="exp-cpu">—</dd>
|
||||
<dt>Memory</dt><dd id="exp-mem">—</dd>
|
||||
<dt>Network</dt><dd id="exp-network">—</dd>
|
||||
<dt>Witness</dt><dd id="exp-witness">—</dd>
|
||||
<dt>Estimated cost</dt><dd id="exp-cost">—</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Capability grants</h3>
|
||||
<ul id="exp-grants" class="notes"></ul>
|
||||
|
||||
<h3>Key capability card</h3>
|
||||
<ul id="exp-card" class="capcard"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Development harness</h3>
|
||||
<p class="hint">
|
||||
No runtime is attached, so a registered agent reports its unknown
|
||||
values: unverified publisher, no confinement engaged, no witness
|
||||
chain. The agent channel below carries the only two values an agent
|
||||
can supply.
|
||||
</p>
|
||||
<div class="row">
|
||||
<input id="dock-new-name" type="text" placeholder="Agent name" />
|
||||
<button id="dock-add">Register agent</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="dock-agent-task" type="text"
|
||||
placeholder="Agent-supplied task text (sanitized on arrival)" />
|
||||
<input id="dock-agent-progress" type="text" class="narrow" placeholder="0–100" />
|
||||
<button id="dock-agent-send">Report as agent</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<p id="error" class="error" role="alert" hidden></p>
|
||||
|
|
@ -123,5 +238,6 @@
|
|||
</footer>
|
||||
|
||||
<script src="main.js"></script>
|
||||
<script src="dock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -156,3 +156,208 @@ input[type="text"] {
|
|||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Agent dock (ADR-295).
|
||||
|
||||
Two presentation systems, deliberately not interchangeable:
|
||||
|
||||
- System chrome (.pill, .state-chip, .ctl, .capcard) is opaque, square-
|
||||
edged, system-font, and sits on --surface. It is drawn from Rust-owned
|
||||
state.
|
||||
- Agent content (.agent-region) is an inset, dashed, monospace region on a
|
||||
tinted background, always preceded by a dock-drawn "Agent-reported"
|
||||
label and clipped to one line. Nothing an agent can put in a string
|
||||
reproduces the label, the inset, or the border, because the agent supplies
|
||||
text through textContent and never markup.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--agent-tint: #241f2e;
|
||||
--agent-line: #4a3f63;
|
||||
--state-idle: #7c8698;
|
||||
--state-running: #4f9bd8;
|
||||
--state-waiting: #d8a13c;
|
||||
--state-paused: #8e7cc3;
|
||||
--state-denied: #d4643c;
|
||||
--state-error: #d94f4f;
|
||||
--state-quarantined: #b03a86;
|
||||
--state-completed: #57b894;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--agent-tint: #f3eefb;
|
||||
--agent-line: #9d8dc4;
|
||||
--state-idle: #667085;
|
||||
--state-running: #1f6fae;
|
||||
--state-waiting: #9a6b12;
|
||||
--state-paused: #6a54a8;
|
||||
--state-denied: #a8431f;
|
||||
--state-error: #b02a2a;
|
||||
--state-quarantined: #8a1f66;
|
||||
--state-completed: #1f7a5c;
|
||||
}
|
||||
}
|
||||
|
||||
.dock-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.dock-empty { color: var(--muted); font-size: 13px; }
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 320px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pill-icon { font-size: 16px; color: var(--accent); }
|
||||
.pill-name { font-weight: 600; white-space: nowrap; }
|
||||
|
||||
/* Agent-authored regions. */
|
||||
.agent-region {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 3px 10px;
|
||||
background: var(--agent-tint);
|
||||
border: 1px dashed var(--agent-line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.agent-region.block { display: flex; margin: 0 0 6px; }
|
||||
|
||||
.agent-label {
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--agent-line);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.agent-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-region.suspicious { border-color: var(--deny); background: transparent; }
|
||||
.agent-region.suspicious .agent-text { color: var(--deny); font-style: italic; }
|
||||
.agent-region.suspicious .agent-label { color: var(--deny); }
|
||||
|
||||
.pill-progress { display: flex; align-items: center; gap: 6px; flex: none; }
|
||||
.pill-progress progress { width: 90px; height: 6px; }
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--state-idle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.state-glyph { font-size: 11px; }
|
||||
|
||||
.state-chip.state-idle { background: var(--state-idle); }
|
||||
.state-chip.state-running { background: var(--state-running); }
|
||||
.state-chip.state-waiting { background: var(--state-waiting); }
|
||||
.state-chip.state-paused { background: var(--state-paused); }
|
||||
.state-chip.state-denied { background: var(--state-denied); }
|
||||
.state-chip.state-error { background: var(--state-error); }
|
||||
.state-chip.state-quarantined { background: var(--state-quarantined); }
|
||||
.state-chip.state-completed { background: var(--state-completed); }
|
||||
|
||||
.ctl { flex: none; padding: 5px 12px; border-radius: 4px; }
|
||||
.ctl.stop { border-color: var(--deny); color: var(--deny); }
|
||||
|
||||
.pill-secondary { display: flex; gap: 6px; }
|
||||
|
||||
.secondary-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondary-icon .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--state-idle); }
|
||||
.secondary-icon .dot.state-idle { background: var(--state-idle); }
|
||||
.secondary-icon .dot.state-running { background: var(--state-running); }
|
||||
.secondary-icon .dot.state-waiting { background: var(--state-waiting); }
|
||||
.secondary-icon .dot.state-paused { background: var(--state-paused); }
|
||||
.secondary-icon .dot.state-denied { background: var(--state-denied); }
|
||||
.secondary-icon .dot.state-error { background: var(--state-error); }
|
||||
.secondary-icon .dot.state-quarantined { background: var(--state-quarantined); }
|
||||
.secondary-icon .dot.state-completed { background: var(--state-completed); }
|
||||
|
||||
.overflow { padding: 5px 10px; font-size: 12px; }
|
||||
|
||||
.dock-aggregate {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.approvals { color: var(--state-waiting); font-weight: 600; }
|
||||
|
||||
.expanded-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.expanded-grid { grid-template-columns: 1fr; }
|
||||
.dock-aggregate { margin-left: 0; }
|
||||
}
|
||||
|
||||
.facts.compact { grid-template-columns: 130px 1fr; font-size: 13px; }
|
||||
|
||||
.actions { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.actions li { margin-bottom: 4px; }
|
||||
.actions li.agent-authored {
|
||||
list-style: none;
|
||||
margin-left: -18px;
|
||||
padding: 2px 8px;
|
||||
border-left: 2px dashed var(--agent-line);
|
||||
background: var(--agent-tint);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.capcard { margin: 0; padding: 0; list-style: none; font-size: 13px; }
|
||||
.capcard li { display: flex; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
.capcard .mark { flex: none; font-weight: 700; width: 1.2em; }
|
||||
.capcard .yes .mark { color: var(--grant); }
|
||||
.capcard .no .mark { color: var(--deny); }
|
||||
.capcard .detail { color: var(--muted); margin-left: auto; text-align: right; }
|
||||
|
||||
input[type="text"].narrow { flex: none; width: 90px; min-width: 0; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue