diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index b61e06d1..2df4cfd6 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5903,6 +5903,19 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "maple-harness" +version = "0.1.0" +dependencies = [ + "jsonschema 0.49.9", + "schemars 1.2.2", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio-util", + "uuid", +] + [[package]] name = "maple-proxy" version = "0.3.4" @@ -8855,6 +8868,7 @@ dependencies = [ "schemars_derive", "serde", "serde_json", + "uuid", ] [[package]] diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 6040ac0f..329fe852 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-code-mode", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-code-mode", "crates/maple-harness", "app"] [workspace.package] edition = "2024" diff --git a/apps/maple-agent/crates/maple-harness/Cargo.toml b/apps/maple-agent/crates/maple-harness/Cargo.toml new file mode 100644 index 00000000..81144a09 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "maple-harness" +description = "GPUI-free semantic action, authority, audit, and keymap core for Maple" +edition.workspace = true +version.workspace = true +license.workspace = true +publish = false + +[dependencies] +jsonschema = { version = "0.49.9", default-features = false } +schemars = { version = "1", features = ["derive", "uuid1"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = "2.0" +tokio-util = { workspace = true } +uuid = { version = "1.25", features = ["serde", "v4"] } diff --git a/apps/maple-agent/crates/maple-harness/src/action.rs b/apps/maple-agent/crates/maple-harness/src/action.rs new file mode 100644 index 00000000..c20e29ec --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/action.rs @@ -0,0 +1,1551 @@ +use std::{fmt, str::FromStr}; + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde_json::{Map, Value}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + audit::AuditSpec, + policy::InvocationPolicy, + semantic::{RevisionDomain, SemanticTarget}, +}; + +const MAX_ACTION_ID_LEN: usize = 128; +const MAX_REASON_CODE_LEN: usize = 64; +const MAX_CONTEXT_PATTERN_LEN: usize = 512; + +/// A stable, wire-facing action identifier. +/// +/// IDs use an intentionally conservative grammar so they survive Rust and UI +/// refactors: at least two dot-separated ASCII segments, each beginning with a +/// lowercase letter and continuing with lowercase letters, digits, or `_`. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ActionId( + #[schemars( + length(min = 3, max = 128), + pattern(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") + )] + String, +); + +impl ActionId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_action_id(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for ActionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for ActionId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl FromStr for ActionId { + type Err = ActionIdError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl TryFrom for ActionId { + type Error = ActionIdError; + + fn try_from(value: String) -> Result { + Self::parse(value) + } +} + +impl TryFrom<&str> for ActionId { + type Error = ActionIdError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for ActionId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionIdError { + #[error("action ID is empty")] + Empty, + #[error("action ID exceeds {MAX_ACTION_ID_LEN} bytes")] + TooLong, + #[error("action ID must contain at least one namespace separator")] + MissingNamespace, + #[error("action ID contains an empty segment at position {segment}")] + EmptySegment { segment: usize }, + #[error("action ID segment {segment} must start with an ASCII lowercase letter")] + InvalidSegmentStart { segment: usize }, + #[error("action ID contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { byte_index: usize, character: char }, +} + +fn validate_action_id(value: &str) -> Result<(), ActionIdError> { + if value.is_empty() { + return Err(ActionIdError::Empty); + } + if value.len() > MAX_ACTION_ID_LEN { + return Err(ActionIdError::TooLong); + } + if !value.contains('.') { + return Err(ActionIdError::MissingNamespace); + } + + let mut byte_offset = 0; + for (segment_index, segment) in value.split('.').enumerate() { + if segment.is_empty() { + return Err(ActionIdError::EmptySegment { + segment: segment_index, + }); + } + if !segment.as_bytes()[0].is_ascii_lowercase() { + return Err(ActionIdError::InvalidSegmentStart { + segment: segment_index, + }); + } + for (relative_index, character) in segment.char_indices() { + if !(character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_') { + return Err(ActionIdError::InvalidCharacter { + byte_index: byte_offset + relative_index, + character, + }); + } + } + byte_offset += segment.len() + 1; + } + Ok(()) +} + +/// Stable machine-readable reason code used by availability and errors. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct DisabledReasonCode( + #[schemars(length(min = 1, max = 64), pattern(r"^[a-z][a-z0-9_]*$"))] String, +); + +impl DisabledReasonCode { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_reason_code(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for DisabledReasonCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for DisabledReasonCode { + type Err = ReasonCodeError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for DisabledReasonCode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ReasonCodeError { + #[error("reason code is empty")] + Empty, + #[error("reason code exceeds {MAX_REASON_CODE_LEN} bytes")] + TooLong, + #[error("reason code must start with an ASCII lowercase letter")] + InvalidStart, + #[error("reason code contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { byte_index: usize, character: char }, +} + +fn validate_reason_code(value: &str) -> Result<(), ReasonCodeError> { + if value.is_empty() { + return Err(ReasonCodeError::Empty); + } + if value.len() > MAX_REASON_CODE_LEN { + return Err(ReasonCodeError::TooLong); + } + if !value.as_bytes()[0].is_ascii_lowercase() { + return Err(ReasonCodeError::InvalidStart); + } + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_') { + return Err(ReasonCodeError::InvalidCharacter { + byte_index, + character, + }); + } + } + Ok(()) +} + +macro_rules! uuid_id { + ($name:ident) => { + #[derive( + Clone, + Copy, + Debug, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub const fn from_uuid(value: Uuid) -> Self { + Self(value) + } + + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } + } + }; +} + +uuid_id!(InvocationId); +uuid_id!(ProgramId); +uuid_id!(ExecutionId); +// Stable identity for asynchronous work that outlives the admitting executor. +// InvocationId identifies the attempt/audit record; OperationId identifies +// the retained cancellation and terminal-completion handle. +uuid_id!(OperationId); + +/// Exact opaque identity assigned by Maple's model-run registry. +/// +/// Model run IDs are deliberately not re-minted as UUIDs at the harness +/// boundary: cancellation, tombstones, audit, and Code Mode must all refer to +/// the same host-owned value losslessly. +#[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct RunId(#[schemars(length(min = 1, max = 4096))] String); + +impl RunId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string()) + } + + pub fn from_host(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(RunIdError::Empty); + } + if value.len() > 4096 { + return Err(RunIdError::TooLong); + } + if value.chars().any(char::is_control) { + return Err(RunIdError::ControlCharacter); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for RunId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for RunId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RunId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Self::from_host(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RunIdError { + #[error("model run ID cannot be empty")] + Empty, + #[error("model run ID exceeds 4096 bytes")] + TooLong, + #[error("model run ID contains a control character")] + ControlCharacter, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TaskIdentity { + pub account_scope: String, + pub task_id: String, +} + +impl TaskIdentity { + pub fn new(account_scope: impl Into, task_id: impl Into) -> Self { + Self { + account_scope: account_scope.into(), + task_id: task_id.into(), + } + } +} + +#[derive(Clone, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct SemanticContextPattern(#[schemars(length(min = 1, max = 512))] String); + +impl SemanticContextPattern { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ContextPatternError::Empty); + } + if value.len() > MAX_CONTEXT_PATTERN_LEN { + return Err(ContextPatternError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(ContextPatternError::InvalidWhitespaceOrControl); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SemanticContextPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for SemanticContextPattern { + type Err = ContextPatternError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for SemanticContextPattern { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ContextPatternError { + #[error("semantic context pattern is empty")] + Empty, + #[error("semantic context pattern exceeds {MAX_CONTEXT_PATTERN_LEN} bytes")] + TooLong, + #[error("semantic context pattern has surrounding whitespace or control characters")] + InvalidWhitespaceOrControl, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionEffect { + Observe, + Navigate, + MutateMaple, + ExternalEffect, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Recoverability { + Ephemeral, + Reversible, + Irreversible, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShortcutProfile { + #[default] + Standard, + Vim, +} + +/// Declares how an action derives its canonical precondition domain from the +/// call's semantic target. +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PreconditionDomainSelector { + Global, + /// Use the complete semantic target as the revision domain. + Target, + /// Derive a task record domain from any task-bearing target. + Task, + Project, + /// Derive a task timeline domain from any task-bearing target. + Timeline, + TimelineItem, + /// Derive a draft domain from any task-bearing target. + Draft, + Setting, + Permission, + Question, + QueueItem, +} + +impl PreconditionDomainSelector { + fn resolve( + self, + target: Option<&SemanticTarget>, + ) -> Result { + if self == Self::Global { + return Ok(RevisionDomain::Global); + } + + let target = + target.ok_or(ActionPreconditionInvariantError::MissingTarget { selector: self })?; + let incompatible = || ActionPreconditionInvariantError::IncompatibleTarget { + selector: self, + target: Box::new(target.clone()), + }; + + match self { + Self::Global => unreachable!("global selector returned before target resolution"), + Self::Target => Ok(RevisionDomain::Target { + target: target.clone(), + }), + Self::Task => target + .task_id() + .map(|task_id| RevisionDomain::Task { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::Project => match target { + SemanticTarget::Project { canonical_root } => Ok(RevisionDomain::Project { + canonical_root: canonical_root.clone(), + }), + _ => Err(incompatible()), + }, + Self::Timeline => target + .task_id() + .map(|task_id| RevisionDomain::Timeline { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::TimelineItem => match target { + SemanticTarget::TimelineItem { task_id, item_id } + | SemanticTarget::Annotation { + task_id, item_id, .. + } => Ok(RevisionDomain::TimelineItem { + task_id: task_id.clone(), + item_id: item_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::Draft => target + .task_id() + .map(|task_id| RevisionDomain::Draft { + task_id: task_id.to_owned(), + }) + .ok_or_else(incompatible), + Self::Setting => match target { + SemanticTarget::Setting { key } => Ok(RevisionDomain::Setting { key: key.clone() }), + _ => Err(incompatible()), + }, + Self::Permission => match target { + SemanticTarget::Permission { request_id } => Ok(RevisionDomain::Permission { + request_id: request_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::Question => match target { + SemanticTarget::Question { + request_id, + question_id, + } => Ok(RevisionDomain::Question { + request_id: request_id.clone(), + question_id: question_id.clone(), + }), + _ => Err(incompatible()), + }, + Self::QueueItem => match target { + SemanticTarget::QueueItem { task_id, queue_id } => Ok(RevisionDomain::QueueItem { + task_id: task_id.clone(), + queue_id: queue_id.clone(), + }), + _ => Err(incompatible()), + }, + } + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefaultBinding { + pub profile: ShortcutProfile, + pub context: String, + pub sequence: String, + #[serde(default = "empty_object")] + pub arguments: Value, +} + +pub type DefaultBindings = Vec; + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionDescriptor { + pub schema_version: u16, + pub id: ActionId, + pub label: String, + pub description: String, + pub category: String, + pub argument_schema: Value, + pub result_schema: Value, + #[serde(default)] + pub contexts: Vec, + pub effect: ActionEffect, + pub invocation_policy: InvocationPolicy, + pub recoverability: Recoverability, + #[serde(default)] + pub audit: AuditSpec, + #[serde(default)] + pub default_bindings: DefaultBindings, + /// The action-declared revision domain accepted in call preconditions. + /// + /// `None` means this action does not accept a precondition. A declared + /// kind permits, but does not require, a call precondition; executors that + /// require compare-and-set semantics may additionally require its + /// presence after this structural check. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precondition_domain: Option, + /// Whether this action participates in the GPUI keymap/action catalog. + #[serde(default)] + pub bindable: bool, + /// True for a host action whose accepted response precedes process shutdown. + #[serde(default)] + pub terminal_host_action: bool, +} + +impl ActionDescriptor { + pub fn validate_arguments(&self, arguments: &Value) -> Result<(), SchemaValidationError> { + validate_instance(&self.argument_schema, arguments) + } + + pub fn validate_result(&self, result: &Value) -> Result<(), SchemaValidationError> { + validate_instance(&self.result_schema, result) + } + + /// Validates that a call uses this descriptor and cannot substitute a + /// different revision domain kind. + pub fn validate_precondition( + &self, + call: &ActionCall, + ) -> Result<(), ActionPreconditionInvariantError> { + if call.action_id != self.id { + return Err(ActionPreconditionInvariantError::ActionMismatch { + expected: self.id.clone(), + actual: call.action_id.clone(), + }); + } + match (self.precondition_domain, call.precondition.as_ref()) { + (None, None) | (Some(_), None) => Ok(()), + (None, Some(_)) => Err(ActionPreconditionInvariantError::UndeclaredPrecondition), + (Some(selector), Some(precondition)) => { + let expected = selector.resolve(call.target.as_ref())?; + if precondition.domain == expected { + Ok(()) + } else { + Err(ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(expected), + actual: Box::new(precondition.domain.clone()), + }) + } + } + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionPreconditionInvariantError { + #[error("call action {actual} does not match descriptor {expected}")] + ActionMismatch { + expected: ActionId, + actual: ActionId, + }, + #[error("action descriptor does not declare a precondition domain")] + UndeclaredPrecondition, + #[error("precondition selector {selector:?} requires a semantic target")] + MissingTarget { + selector: PreconditionDomainSelector, + }, + #[error("semantic target {target:?} cannot define precondition selector {selector:?}")] + IncompatibleTarget { + selector: PreconditionDomainSelector, + target: Box, + }, + #[error("precondition domain {actual:?} does not match canonical domain {expected:?}")] + DomainMismatch { + expected: Box, + actual: Box, + }, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SchemaValidationError { + #[error("invalid JSON schema: {0}")] + InvalidSchema(String), + #[error("value does not match schema: {0}")] + InvalidValue(String), +} + +fn validate_instance(schema: &Value, instance: &Value) -> Result<(), SchemaValidationError> { + let validator = jsonschema::validator_for(schema) + .map_err(|error| SchemaValidationError::InvalidSchema(error.to_string()))?; + if validator.is_valid(instance) { + Ok(()) + } else { + let message = validator + .iter_errors(instance) + .next() + .map(|error| error.masked().to_string()) + .unwrap_or_else(|| "unknown validation error".to_owned()); + Err(SchemaValidationError::InvalidValue(message)) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionCall { + pub action_id: ActionId, + #[serde(default = "empty_object")] + pub arguments: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub precondition: Option, +} + +impl ActionCall { + pub fn new(action_id: ActionId) -> Self { + Self { + action_id, + arguments: empty_object(), + target: None, + precondition: None, + } + } + + pub fn with_arguments(mut self, arguments: Value) -> Self { + self.arguments = arguments; + self + } + + pub fn with_target(mut self, target: SemanticTarget) -> Self { + self.target = Some(target); + self + } + + pub fn with_precondition(mut self, precondition: ActionPrecondition) -> Self { + self.precondition = Some(precondition); + self + } +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionPrecondition { + pub domain: RevisionDomain, + pub target_revision: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Availability { + Available, + Disabled { + code: DisabledReasonCode, + message: String, + }, +} + +impl Availability { + pub const fn is_available(&self) -> bool { + matches!(self, Self::Available) + } + + pub fn disabled(code: DisabledReasonCode, message: impl Into) -> Self { + Self::Disabled { + code, + message: message.into(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionStatus { + Accepted, + AcceptedTerminal, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActionErrorCode { + UnknownAction, + InvalidArguments, + NotApplicable, + Unavailable, + PolicyDenied, + StaleTarget, + Cancelled, + Failed, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ActionError { + pub code: ActionErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ActionError { + /// Builds an error whose code does not require additional structured + /// metadata. Use [`Self::unavailable`] for `Unavailable` errors. + pub fn new( + code: ActionErrorCode, + message: impl Into, + ) -> Result { + let error = Self { + code, + message: message.into(), + reason_code: None, + details: None, + }; + error.validate()?; + Ok(error) + } + + pub fn unavailable(code: DisabledReasonCode, message: impl Into) -> Self { + Self { + code: ActionErrorCode::Unavailable, + message: message.into(), + reason_code: Some(code), + details: None, + } + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + pub fn validate(&self) -> Result<(), ActionErrorInvariantError> { + match (self.code, self.reason_code.as_ref()) { + (ActionErrorCode::Unavailable, None) => { + Err(ActionErrorInvariantError::UnavailableMissingReasonCode) + } + (ActionErrorCode::Unavailable, Some(_)) | (_, None) => Ok(()), + (code, Some(_)) => Err(ActionErrorInvariantError::UnexpectedReasonCode { code }), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ActionErrorWire { + code: ActionErrorCode, + message: String, + #[serde(default)] + reason_code: Option, + #[serde(default)] + details: Option, +} + +#[derive(Serialize)] +struct ActionErrorWireRef<'a> { + code: ActionErrorCode, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + reason_code: Option<&'a DisabledReasonCode>, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option<&'a Value>, +} + +impl Serialize for ActionError { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ActionErrorWireRef { + code: self.code, + message: &self.message, + reason_code: self.reason_code.as_ref(), + details: self.details.as_ref(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ActionError { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ActionErrorWire::deserialize(deserializer)?; + let error = Self { + code: wire.code, + message: wire.message, + reason_code: wire.reason_code, + details: wire.details, + }; + error.validate().map_err(de::Error::custom)?; + Ok(error) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionErrorInvariantError { + #[error("unavailable action error is missing a stable reason code")] + UnavailableMissingReasonCode, + #[error("action error code {code:?} must not include an unavailable reason code")] + UnexpectedReasonCode { code: ActionErrorCode }, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ActionResponse { + pub invocation_id: InvocationId, + pub action_id: ActionId, + pub status: ActionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_revision: Option, +} + +impl ActionResponse { + /// Builds an asynchronous acceptance response with a fresh host operation + /// identity. The operation ID is part of the result envelope so action + /// result schemas and generated SDKs expose it without conflating it with + /// the invocation/audit identity. + pub fn accepted(invocation_id: InvocationId, action_id: ActionId, result: Value) -> Self { + Self::accepted_with_operation_id(invocation_id, action_id, OperationId::new(), result) + } + + /// Deterministic form used by a host that reserves an operation identity + /// before handing work to a backend. Existing `operation_id` input is + /// overwritten: an executor cannot select a caller-provided identity. + pub fn accepted_with_operation_id( + invocation_id: InvocationId, + action_id: ActionId, + operation_id: OperationId, + mut result: Value, + ) -> Self { + if let Some(result) = result.as_object_mut() { + result.insert( + "operation_id".to_string(), + Value::String(operation_id.to_string()), + ); + } + Self { + invocation_id, + action_id, + status: ActionStatus::Accepted, + result: Some(result), + error: None, + state_revision: None, + } + } + + /// Returns the stable operation identity for an accepted response. + /// Non-accepted responses deliberately have no operation handle. + pub fn operation_id(&self) -> Result, ActionResponseInvariantError> { + if self.status != ActionStatus::Accepted { + return Ok(None); + } + let value = self + .result + .as_ref() + .and_then(Value::as_object) + .and_then(|result| result.get("operation_id")) + .ok_or(ActionResponseInvariantError::AcceptedMissingOperationId)?; + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|_| ActionResponseInvariantError::AcceptedInvalidOperationId) + } + + pub fn accepted_terminal( + invocation_id: InvocationId, + action_id: ActionId, + result: Value, + ) -> Self { + Self { + invocation_id, + action_id, + status: ActionStatus::AcceptedTerminal, + result: Some(result), + error: None, + state_revision: None, + } + } + + pub fn completed( + invocation_id: InvocationId, + action_id: ActionId, + result: Value, + state_revision: Option, + ) -> Self { + Self { + invocation_id, + action_id, + status: ActionStatus::Completed, + result: Some(result), + error: None, + state_revision, + } + } + + pub fn failed( + invocation_id: InvocationId, + action_id: ActionId, + error: ActionError, + ) -> Result { + let status = if error.code == ActionErrorCode::Cancelled { + ActionStatus::Cancelled + } else { + ActionStatus::Failed + }; + let response = Self { + invocation_id, + action_id, + status, + result: None, + error: Some(error), + state_revision: None, + }; + response.validate()?; + Ok(response) + } + + pub fn validate(&self) -> Result<(), ActionResponseInvariantError> { + if let Some(error) = &self.error { + error.validate()?; + } + + match self.status { + ActionStatus::Accepted => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + self.operation_id()?; + } + ActionStatus::AcceptedTerminal => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + } + ActionStatus::Completed => { + if self.error.is_some() { + return Err(ActionResponseInvariantError::SuccessHasError); + } + if self.result.is_none() { + return Err(ActionResponseInvariantError::CompletedMissingResult); + } + } + ActionStatus::Failed => { + if self.error.is_none() { + return Err(ActionResponseInvariantError::FailureMissingError); + } + if self.result.is_some() { + return Err(ActionResponseInvariantError::FailureHasResult); + } + if self.error.as_ref().map(|error| error.code) == Some(ActionErrorCode::Cancelled) { + return Err(ActionResponseInvariantError::FailedHasCancelledError); + } + } + ActionStatus::Cancelled => { + let Some(error) = &self.error else { + return Err(ActionResponseInvariantError::FailureMissingError); + }; + if self.result.is_some() { + return Err(ActionResponseInvariantError::FailureHasResult); + } + if error.code != ActionErrorCode::Cancelled { + return Err( + ActionResponseInvariantError::CancelledHasNonCancelledError { + actual: error.code, + }, + ); + } + } + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ActionResponseWire { + invocation_id: InvocationId, + action_id: ActionId, + status: ActionStatus, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, + #[serde(default)] + state_revision: Option, +} + +#[derive(Serialize)] +struct ActionResponseWireRef<'a> { + invocation_id: InvocationId, + action_id: &'a ActionId, + status: ActionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a ActionError>, + #[serde(skip_serializing_if = "Option::is_none")] + state_revision: Option, +} + +impl Serialize for ActionResponse { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ActionResponseWireRef { + invocation_id: self.invocation_id, + action_id: &self.action_id, + status: self.status, + result: self.result.as_ref(), + error: self.error.as_ref(), + state_revision: self.state_revision, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ActionResponse { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = ActionResponseWire::deserialize(deserializer)?; + let response = Self { + invocation_id: wire.invocation_id, + action_id: wire.action_id, + status: wire.status, + result: wire.result, + error: wire.error, + state_revision: wire.state_revision, + }; + response.validate().map_err(de::Error::custom)?; + Ok(response) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ActionResponseInvariantError { + #[error(transparent)] + InvalidError(#[from] ActionErrorInvariantError), + #[error("successful or accepted action response contains an error")] + SuccessHasError, + #[error("accepted action response is missing a stable operation_id result field")] + AcceptedMissingOperationId, + #[error("accepted action response contains an invalid operation_id")] + AcceptedInvalidOperationId, + #[error("completed action response is missing a result")] + CompletedMissingResult, + #[error("failed or cancelled action response is missing an error")] + FailureMissingError, + #[error("failed or cancelled action response contains a result")] + FailureHasResult, + #[error("failed action response contains a cancelled error")] + FailedHasCancelledError, + #[error("cancelled action response contains non-cancelled error {actual:?}")] + CancelledHasNonCancelledError { actual: ActionErrorCode }, +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: 1, + id: ActionId::parse(id).unwrap(), + label: "Test action".into(), + description: "Test action descriptor".into(), + category: "Tests".into(), + argument_schema: json!({"type": "object"}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect: ActionEffect::MutateMaple, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Reversible, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + precondition_domain: None, + bindable: false, + terminal_host_action: false, + } + } + + #[test] + fn stable_action_ids_accept_the_normative_grammar() { + for valid in [ + "app.quit", + "task.set_archived", + "composer.vim.count_digit", + "settings.set_model2", + ] { + assert_eq!(ActionId::parse(valid).unwrap().as_str(), valid); + } + } + + #[test] + fn stable_action_ids_reject_ambiguous_or_unstable_spellings() { + for invalid in [ + "", + "quit", + ".quit", + "app.", + "app..quit", + "App.quit", + "app.Quit", + "app. quit", + "app.-quit", + "app.quît", + ] { + assert!(ActionId::parse(invalid).is_err(), "accepted {invalid:?}"); + assert!( + serde_json::from_value::(json!(invalid)).is_err(), + "deserialized {invalid:?}" + ); + } + } + + #[test] + fn model_run_ids_preserve_host_owned_opaque_identity() { + let value = "run_1788062400000_17"; + let run = RunId::from_host(value).unwrap(); + assert_eq!(run.as_str(), value); + assert_eq!(serde_json::to_value(&run).unwrap(), json!(value)); + assert_eq!(serde_json::from_value::(json!(value)).unwrap(), run); + assert_eq!(RunId::from_host(" ").unwrap_err(), RunIdError::Empty); + } + + #[test] + fn argument_and_result_schemas_validate_wire_values() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.argument_schema = json!({ + "type": "object", + "required": ["task_id", "archived"], + "properties": { + "task_id": {"type": "string"}, + "archived": {"type": "boolean"} + }, + "additionalProperties": false + }); + action_descriptor.result_schema = json!({ + "type": "object", + "required": ["changed"], + "properties": {"changed": {"type": "boolean"}} + }); + action_descriptor.bindable = true; + + assert!( + action_descriptor + .validate_arguments(&json!({"task_id": "t1", "archived": true})) + .is_ok() + ); + assert!( + action_descriptor + .validate_arguments(&json!({"task_id": "t1", "archived": "yes"})) + .is_err() + ); + assert!( + action_descriptor + .validate_result(&json!({"changed": true})) + .is_ok() + ); + assert!(action_descriptor.validate_result(&json!({})).is_err()); + + let round_trip: ActionDescriptor = + serde_json::from_value(serde_json::to_value(&action_descriptor).unwrap()).unwrap(); + assert_eq!(round_trip, action_descriptor); + } + + #[test] + fn schema_validation_diagnostics_mask_offending_secret_values() { + let sentinel = "MAPLE_SECRET_SENTINEL_9f31"; + let error = validate_instance(&json!({"type": "string", "maxLength": 2}), &json!(sentinel)) + .unwrap_err(); + assert!(!error.to_string().contains(sentinel)); + } + + #[test] + fn controller_wire_types_generate_schemars_one_schemas_for_gpui_adapters() { + let action_call = schemars::schema_for!(ActionCall); + let target = schemars::schema_for!(SemanticTarget); + let precondition = schemars::schema_for!(ActionPrecondition); + + for schema in [action_call, target, precondition] { + let value = serde_json::to_value(schema).unwrap(); + assert!(value.is_object()); + assert!(value.get("$schema").is_some()); + } + } + + #[test] + fn unavailable_error_has_stable_reason_and_human_copy() { + let error = ActionError::unavailable( + DisabledReasonCode::parse("no_annotations").unwrap(), + "No annotations exist in this task", + ); + assert_eq!(error.code, ActionErrorCode::Unavailable); + assert_eq!(error.reason_code.unwrap().as_str(), "no_annotations"); + } + + #[test] + fn action_errors_enforce_unavailable_reason_code_in_both_directions() { + assert_eq!( + ActionError::new(ActionErrorCode::Unavailable, "not ready").unwrap_err(), + ActionErrorInvariantError::UnavailableMissingReasonCode + ); + assert!(ActionError::new(ActionErrorCode::Failed, "failed").is_ok()); + + assert!( + serde_json::from_value::(json!({ + "code": "unavailable", + "message": "not ready" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "code": "failed", + "message": "failed", + "reason_code": "not_ready" + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "code": "unavailable", + "message": "not ready", + "reason_code": "not_ready" + })) + .is_ok() + ); + + let forged = ActionError { + code: ActionErrorCode::Failed, + message: "failed".into(), + reason_code: Some(DisabledReasonCode::parse("not_ready").unwrap()), + details: None, + }; + assert!(serde_json::to_value(forged).is_err()); + } + + #[test] + fn descriptor_rejects_caller_selected_precondition_domain_kind() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + + let matching = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-1".into(), + }, + target_revision: 7, + }); + assert!(action_descriptor.validate_precondition(&matching).is_ok()); + + let substituted = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 7, + }); + assert_eq!( + action_descriptor + .validate_precondition(&substituted) + .unwrap_err(), + ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(RevisionDomain::Task { + task_id: "task-1".into(), + }), + actual: Box::new(RevisionDomain::Global), + } + ); + + let undeclared_call = ActionCall::new(ActionId::parse("task.open").unwrap()) + .with_target(SemanticTarget::Task { + task_id: "task-1".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-1".into(), + }, + target_revision: 7, + }); + let undeclared = descriptor("task.open").validate_precondition(&undeclared_call); + assert_eq!( + undeclared.unwrap_err(), + ActionPreconditionInvariantError::UndeclaredPrecondition + ); + } + + #[test] + fn descriptor_rejects_same_kind_precondition_for_a_different_identity() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + let call = ActionCall::new(action_descriptor.id.clone()) + .with_target(SemanticTarget::Task { + task_id: "task-a".into(), + }) + .with_precondition(ActionPrecondition { + domain: RevisionDomain::Task { + task_id: "task-b".into(), + }, + target_revision: 7, + }); + + assert_eq!( + action_descriptor.validate_precondition(&call).unwrap_err(), + ActionPreconditionInvariantError::DomainMismatch { + expected: Box::new(RevisionDomain::Task { + task_id: "task-a".into(), + }), + actual: Box::new(RevisionDomain::Task { + task_id: "task-b".into(), + }), + } + ); + } + + #[test] + fn descriptor_precondition_validation_is_bound_to_the_action_id() { + let mut action_descriptor = descriptor("task.set_archived"); + action_descriptor.precondition_domain = Some(PreconditionDomainSelector::Task); + let other = ActionCall::new(ActionId::parse("task.open").unwrap()); + + assert!(matches!( + action_descriptor.validate_precondition(&other), + Err(ActionPreconditionInvariantError::ActionMismatch { .. }) + )); + } + + #[test] + fn response_invariants_distinguish_acceptance_from_completion() { + let invocation_id = InvocationId::new(); + let action_id = ActionId::parse("task.open").unwrap(); + let accepted = ActionResponse::accepted( + invocation_id, + action_id.clone(), + json!({ + "operation_id": "op-1" + }), + ); + assert_eq!(accepted.status, ActionStatus::Accepted); + assert!(accepted.validate().is_ok()); + + let completed = + ActionResponse::completed(invocation_id, action_id, json!({"task_id": "t1"}), Some(4)); + assert_eq!(completed.status, ActionStatus::Completed); + assert!(completed.validate().is_ok()); + } + + #[test] + fn response_deserialization_rejects_inconsistent_status_result_and_error() { + let invocation_id = InvocationId::new(); + let action_id = ActionId::parse("task.open").unwrap(); + let base = json!({ + "invocation_id": invocation_id, + "action_id": action_id, + }); + + let cases = [ + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "completed" + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "error": {"code": "failed", "message": "failed"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {"operation_id": "caller-selected"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed" + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed", + "result": {}, + "error": {"code": "failed", "message": "failed"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "failed", + "error": {"code": "cancelled", "message": "cancelled"} + }), + json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "cancelled", + "error": {"code": "failed", "message": "failed"} + }), + ]; + + assert!(base.is_object()); + for case in cases { + assert!( + serde_json::from_value::(case.clone()).is_err(), + "accepted inconsistent response {case}" + ); + } + + assert!( + serde_json::from_value::(json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "accepted", + "result": {"operation_id": OperationId::new()} + })) + .is_ok() + ); + assert!( + serde_json::from_value::(json!({ + "invocation_id": invocation_id, + "action_id": action_id, + "status": "cancelled", + "error": {"code": "cancelled", "message": "cancelled"} + })) + .is_ok() + ); + + let forged = ActionResponse { + invocation_id, + action_id, + status: ActionStatus::Completed, + result: None, + error: None, + state_revision: None, + }; + assert!(serde_json::to_value(forged).is_err()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/audit.rs b/apps/maple-agent/crates/maple-harness/src/audit.rs new file mode 100644 index 00000000..3af6710d --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/audit.rs @@ -0,0 +1,869 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionEffect, ActionErrorCode, ActionId, InvocationActor, InvocationId, InvocationTransport, + PolicyDecision, ProgramId, Recoverability, RunId, SemanticTarget, UiControllerAccess, +}; + +pub const DEFAULT_ACTION_AUDIT_CAPACITY: usize = 512; + +/// Descriptor-owned audit rules. Argument capture is deny-by-default and only +/// explicitly allowlisted top-level scalar fields can enter the audit ring. +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AuditSpec { + pub safe_argument_fields: BTreeSet, + /// Marks an action whose arguments may contain authentication, credential, + /// or other secret material. Such actions must redact every argument. + pub secret_bearing: bool, +} + +impl AuditSpec { + pub fn redact_all() -> Self { + Self::default() + } + + pub fn allow_fields( + fields: impl IntoIterator>, + ) -> Result { + let spec = Self { + safe_argument_fields: fields.into_iter().map(Into::into).collect(), + secret_bearing: false, + }; + spec.validate()?; + Ok(spec) + } + + pub fn secret_bearing() -> Self { + Self { + safe_argument_fields: BTreeSet::new(), + secret_bearing: true, + } + } + + pub fn validate(&self) -> Result<(), AuditSpecError> { + if self.secret_bearing && !self.safe_argument_fields.is_empty() { + return Err(AuditSpecError::SecretBearingAllowlist); + } + for field in &self.safe_argument_fields { + if field.is_empty() || field.trim() != field { + return Err(AuditSpecError::InvalidField(field.clone())); + } + if field_is_sensitive(field) { + return Err(AuditSpecError::SensitiveField(field.clone())); + } + } + Ok(()) + } + + pub fn redact(&self, arguments: &Value) -> RedactedArguments { + let Some(object) = arguments.as_object() else { + return RedactedArguments { + fields: BTreeMap::new(), + redacted_field_count: usize::from(!arguments.is_null()), + }; + }; + + let mut fields = BTreeMap::new(); + let mut redacted_field_count = 0; + for (key, value) in object { + if !self.secret_bearing + && self.safe_argument_fields.contains(key) + && !field_is_sensitive(key) + && is_safe_scalar(value) + { + fields.insert(key.clone(), value.clone()); + } else { + redacted_field_count += 1; + } + } + RedactedArguments { + fields, + redacted_field_count, + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum AuditSpecError { + #[error("secret-bearing actions must redact every argument")] + SecretBearingAllowlist, + #[error("audit allowlist field {0:?} is empty or has surrounding whitespace")] + InvalidField(String), + #[error("audit allowlist field {0:?} appears sensitive and cannot be retained")] + SensitiveField(String), +} + +fn is_safe_scalar(value: &Value) -> bool { + matches!(value, Value::Bool(_) | Value::Number(_) | Value::String(_)) +} + +fn field_is_sensitive(field: &str) -> bool { + let normalized = field.to_ascii_lowercase(); + [ + "password", + "passwd", + "secret", + "token", + "credential", + "authorization", + "cookie", + "oauth", + "callback", + "private_key", + "api_key", + "header", + "environment", + "python_code", + "python_output", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RedactedArguments { + fields: BTreeMap, + redacted_field_count: usize, +} + +impl RedactedArguments { + pub fn fields(&self) -> &BTreeMap { + &self.fields + } + + pub fn redacted_field_count(&self) -> usize { + self.redacted_field_count + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationActorSummary { + DirectUser, + Model, + UserCode, + Internal, +} + +impl From for InvocationActorSummary { + fn from(actor: InvocationActor) -> Self { + match actor { + InvocationActor::DirectUser => Self::DirectUser, + InvocationActor::Model => Self::Model, + InvocationActor::UserCode => Self::UserCode, + InvocationActor::Internal => Self::Internal, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditOutcome { + Denied, + Started, + Accepted, + AcceptedTerminal, + Completed, + Failed, + Cancelled, + CompletedAfterCancelRequest, +} + +impl AuditOutcome { + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Denied + | Self::AcceptedTerminal + | Self::Completed + | Self::Failed + | Self::Cancelled + | Self::CompletedAfterCancelRequest + ) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ActionAuditRecord { + pub sequence: u64, + pub invocation_id: InvocationId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_run_id: Option, + pub timestamp_ms: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancel_requested_at_ms: Option, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub recoverability: Recoverability, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, +} + +#[derive(Clone, Debug)] +pub struct ActionAuditDraft { + pub invocation_id: InvocationId, + pub program_id: Option, + pub model_run_id: Option, + pub timestamp_ms: i64, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub recoverability: Recoverability, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, + pub error_code: Option, +} + +#[derive(Clone, Debug)] +pub struct ActionAuditRing { + capacity: usize, + next_sequence: u64, + records: VecDeque, +} + +impl ActionAuditRing { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(AuditRingError::ZeroCapacity); + } + Ok(Self { + capacity, + next_sequence: 1, + records: VecDeque::with_capacity(capacity), + }) + } + + /// Retains a new audit record without ever evicting an invocation whose + /// terminal outcome has not been observed yet. + /// + /// When the ring is full, the oldest terminal record is evicted. If every + /// retained record is still pending, the caller must apply backpressure (or + /// otherwise fail the attempted invocation) rather than losing the audit + /// trail needed to finish an in-flight invocation. + pub fn record(&mut self, draft: ActionAuditDraft) -> Result { + if draft.outcome == AuditOutcome::CompletedAfterCancelRequest { + return Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + draft.invocation_id, + )); + } + if self.records.len() == self.capacity { + let oldest_terminal = self + .records + .iter() + .position(|record| record.outcome.is_terminal()) + .ok_or(AuditRingError::CapacityExceeded { + capacity: self.capacity, + })?; + self.records.remove(oldest_terminal); + } + + let record = ActionAuditRecord { + sequence: self.next_sequence, + invocation_id: draft.invocation_id, + program_id: draft.program_id, + model_run_id: draft.model_run_id, + timestamp_ms: draft.timestamp_ms, + duration_ms: None, + cancel_requested_at_ms: None, + actor: draft.actor, + transport: draft.transport, + controller_access: draft.controller_access, + policy_epoch: draft.policy_epoch, + action_id: draft.action_id, + target: draft.target, + arguments: draft.arguments, + effect: draft.effect, + recoverability: draft.recoverability, + decision: draft.decision, + outcome: draft.outcome, + error_code: draft.error_code, + }; + self.next_sequence = self.next_sequence.saturating_add(1); + self.records.push_back(record.clone()); + Ok(record) + } + + /// Records lifecycle evidence that cancellation was requested for an + /// in-flight invocation. Repeated calls are idempotent and preserve the + /// first request timestamp. + pub fn mark_cancel_requested( + &mut self, + invocation_id: InvocationId, + timestamp_ms: i64, + ) -> Result { + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + if record.outcome.is_terminal() { + return Err(AuditRingError::AlreadyTerminal(invocation_id)); + } + if timestamp_ms < record.timestamp_ms { + return Err(AuditRingError::CancelRequestBeforeStart { + invocation_id, + started_at_ms: record.timestamp_ms, + requested_at_ms: timestamp_ms, + }); + } + if record.cancel_requested_at_ms.is_none() { + record.cancel_requested_at_ms = Some(timestamp_ms); + } + Ok(record.clone()) + } + + /// Records the nonterminal boundary where an executor handed ownership to + /// a retained asynchronous operation. Replaying the same acceptance is + /// idempotent so UI/controller delivery retries cannot manufacture a + /// second lifecycle transition. + pub fn accept( + &mut self, + invocation_id: InvocationId, + ) -> Result { + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + match record.outcome { + AuditOutcome::Started => record.outcome = AuditOutcome::Accepted, + AuditOutcome::Accepted => {} + _ => return Err(AuditRingError::AlreadyTerminal(invocation_id)), + } + Ok(record.clone()) + } + + pub fn finish( + &mut self, + invocation_id: InvocationId, + duration_ms: u64, + outcome: AuditOutcome, + error_code: Option, + ) -> Result { + if !outcome.is_terminal() { + return Err(AuditRingError::NonTerminalFinish(outcome)); + } + let record = self + .records + .iter_mut() + .rev() + .find(|record| record.invocation_id == invocation_id) + .ok_or(AuditRingError::InvocationNotRetained(invocation_id))?; + if record.outcome.is_terminal() { + return Err(AuditRingError::AlreadyTerminal(invocation_id)); + } + if outcome == AuditOutcome::CompletedAfterCancelRequest { + if record.effect != ActionEffect::ExternalEffect + && record.effect != ActionEffect::MutateMaple + { + return Err(AuditRingError::InvalidCompletedAfterCancelEffect); + } + if record.recoverability != Recoverability::Irreversible { + return Err(AuditRingError::CompletedAfterCancelRequiresIrreversible); + } + if record.cancel_requested_at_ms.is_none() { + return Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + invocation_id, + )); + } + } else if outcome == AuditOutcome::Completed + && record.recoverability == Recoverability::Irreversible + && record.cancel_requested_at_ms.is_some() + { + return Err(AuditRingError::CompletedAfterCancelOutcomeRequired( + invocation_id, + )); + } + record.duration_ms = Some(duration_ms); + record.outcome = outcome; + record.error_code = error_code; + Ok(record.clone()) + } + + pub fn records(&self) -> impl DoubleEndedIterator { + self.records.iter() + } + + pub fn recent(&self, limit: usize) -> Vec { + self.records.iter().rev().take(limit).cloned().collect() + } + + pub fn len(&self) -> usize { + self.records.len() + } + + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } +} + +impl Default for ActionAuditRing { + fn default() -> Self { + Self::new(DEFAULT_ACTION_AUDIT_CAPACITY) + .expect("default action audit ring capacity is nonzero") + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum AuditRingError { + #[error("action audit ring capacity must be greater than zero")] + ZeroCapacity, + #[error("action audit ring capacity {capacity} is exhausted by nonterminal invocations")] + CapacityExceeded { capacity: usize }, + #[error("cannot finish an audit record with nonterminal outcome {0:?}")] + NonTerminalFinish(AuditOutcome), + #[error("invocation {0} is not retained in the bounded audit ring")] + InvocationNotRetained(InvocationId), + #[error("invocation {0} already has a terminal audit outcome")] + AlreadyTerminal(InvocationId), + #[error( + "cancel request for invocation {invocation_id} at {requested_at_ms} predates its audit start at {started_at_ms}" + )] + CancelRequestBeforeStart { + invocation_id: InvocationId, + started_at_ms: i64, + requested_at_ms: i64, + }, + #[error("completed_after_cancel_request is invalid for an observe/navigation action")] + InvalidCompletedAfterCancelEffect, + #[error("completed_after_cancel_request requires irreversible recoverability")] + CompletedAfterCancelRequiresIrreversible, + #[error("completed_after_cancel_request requires a recorded cancel request for invocation {0}")] + CompletedAfterCancelWithoutCancelRequest(InvocationId), + #[error( + "invocation {0} completed after cancellation and requires completed_after_cancel_request" + )] + CompletedAfterCancelOutcomeRequired(InvocationId), +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ActionId, PolicyDenialCode}; + + fn draft( + invocation_id: InvocationId, + arguments: RedactedArguments, + outcome: AuditOutcome, + ) -> ActionAuditDraft { + ActionAuditDraft { + invocation_id, + program_id: Some(ProgramId::new()), + model_run_id: None, + timestamp_ms: 1_000, + actor: InvocationActorSummary::Model, + transport: InvocationTransport::Python, + controller_access: UiControllerAccess::FullAccess, + policy_epoch: 3, + action_id: ActionId::parse("permission.respond").unwrap(), + target: Some(SemanticTarget::Permission { + request_id: "request-1".into(), + }), + arguments, + effect: ActionEffect::MutateMaple, + recoverability: Recoverability::Reversible, + decision: PolicyDecision::Allowed, + outcome, + error_code: None, + } + } + + #[test] + fn redaction_is_descriptor_driven_and_deny_by_default() { + let spec = AuditSpec::allow_fields(["task_id", "archived"]).unwrap(); + let redacted = spec.redact(&json!({ + "task_id": "task-1", + "archived": true, + "password": "do-not-log", + "nested": {"token": "also-do-not-log"} + })); + assert_eq!(redacted.fields()["task_id"], "task-1"); + assert_eq!(redacted.fields()["archived"], true); + assert_eq!(redacted.redacted_field_count(), 2); + let encoded = serde_json::to_string(&redacted).unwrap(); + assert!(!encoded.contains("do-not-log")); + assert!(!encoded.contains("also-do-not-log")); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("token")); + } + + #[test] + fn unsafe_secret_bearing_allowlists_are_rejected() { + assert!(AuditSpec::allow_fields(["access_token"]).is_err()); + let mut spec = AuditSpec::secret_bearing(); + spec.safe_argument_fields.insert("task_id".into()); + assert_eq!(spec.validate(), Err(AuditSpecError::SecretBearingAllowlist)); + } + + #[test] + fn bounded_ring_evicts_oldest_and_keeps_monotonic_sequence() { + let mut ring = ActionAuditRing::new(2).unwrap(); + let first = ring + .record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + ring.record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + let third = ring + .record(draft( + InvocationId::new(), + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + assert_eq!(ring.len(), 2); + assert_eq!(first.sequence, 1); + assert_eq!(third.sequence, 3); + assert!(ring.records().all(|record| record.sequence != 1)); + } + + #[test] + fn full_ring_never_evicts_nonterminal_records() { + let first_id = InvocationId::new(); + let second_id = InvocationId::new(); + let rejected_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + let first = ring + .record(draft( + first_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + let second = ring + .record(draft( + second_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + )) + .unwrap(); + + assert_eq!( + ring.record(draft( + rejected_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )), + Err(AuditRingError::CapacityExceeded { capacity: 2 }) + ); + assert_eq!( + ring.records() + .map(|record| (record.sequence, record.invocation_id)) + .collect::>(), + vec![(first.sequence, first_id), (second.sequence, second_id)] + ); + + let completed = ring + .finish(first_id, 17, AuditOutcome::Completed, None) + .unwrap(); + assert_eq!(completed.sequence, first.sequence); + + let replacement = ring + .record(draft( + rejected_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + assert_eq!(replacement.sequence, 3); + assert_eq!( + ring.records() + .map(|record| record.invocation_id) + .collect::>(), + vec![second_id, rejected_id] + ); + } + + #[test] + fn full_ring_evicts_oldest_terminal_even_when_pending_record_is_older() { + let pending_id = InvocationId::new(); + let first_terminal_id = InvocationId::new(); + let second_terminal_id = InvocationId::new(); + let replacement_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(3).unwrap(); + + ring.record(draft( + pending_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + ring.record(draft( + first_terminal_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + ring.record(draft( + second_terminal_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Completed, + )) + .unwrap(); + + ring.record(draft( + replacement_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap(); + + assert_eq!( + ring.records() + .map(|record| record.invocation_id) + .collect::>(), + vec![pending_id, second_terminal_id, replacement_id] + ); + assert!( + ring.finish(pending_id, 23, AuditOutcome::Completed, None) + .is_ok() + ); + } + + #[test] + fn async_audit_is_not_completed_until_terminal_result_is_known() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(8).unwrap(); + let started = ring + .record(draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + assert_eq!(started.outcome, AuditOutcome::Started); + assert_eq!(started.duration_ms, None); + + let accepted = ring.accept(invocation_id).unwrap(); + assert_eq!(accepted.outcome, AuditOutcome::Accepted); + assert_eq!(ring.accept(invocation_id).unwrap(), accepted); + assert_eq!(accepted.duration_ms, None); + + let completed = ring + .finish(invocation_id, 42, AuditOutcome::Completed, None) + .unwrap(); + assert_eq!(completed.outcome, AuditOutcome::Completed); + assert_eq!(completed.duration_ms, Some(42)); + assert!(matches!( + ring.finish(invocation_id, 50, AuditOutcome::Cancelled, None), + Err(AuditRingError::AlreadyTerminal(_)) + )); + } + + #[test] + fn completed_after_cancel_requires_irreversible_recoverability_and_cancel_evidence() { + let reversible_id = InvocationId::new(); + let unmarked_irreversible_id = InvocationId::new(); + let raced_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(4).unwrap(); + + ring.record(draft( + reversible_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + )) + .unwrap(); + ring.mark_cancel_requested(reversible_id, 1_100).unwrap(); + assert_eq!( + ring.finish( + reversible_id, + 100, + AuditOutcome::CompletedAfterCancelRequest, + None, + ), + Err(AuditRingError::CompletedAfterCancelRequiresIrreversible) + ); + + let mut unmarked_irreversible = draft( + unmarked_irreversible_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + unmarked_irreversible.recoverability = Recoverability::Irreversible; + ring.record(unmarked_irreversible).unwrap(); + assert_eq!( + ring.finish( + unmarked_irreversible_id, + 100, + AuditOutcome::CompletedAfterCancelRequest, + None, + ), + Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + unmarked_irreversible_id + )) + ); + + let mut raced = draft( + raced_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + raced.recoverability = Recoverability::Irreversible; + ring.record(raced).unwrap(); + let marked = ring.mark_cancel_requested(raced_id, 1_125).unwrap(); + assert_eq!(marked.cancel_requested_at_ms, Some(1_125)); + let completed = ring + .finish( + raced_id, + 125, + AuditOutcome::CompletedAfterCancelRequest, + None, + ) + .unwrap(); + assert_eq!(completed.recoverability, Recoverability::Irreversible); + assert_eq!(completed.cancel_requested_at_ms, Some(1_125)); + assert_eq!(completed.outcome, AuditOutcome::CompletedAfterCancelRequest); + let encoded = serde_json::to_value(completed).unwrap(); + assert_eq!(encoded["recoverability"], "irreversible"); + assert_eq!(encoded["cancel_requested_at_ms"], 1_125); + } + + #[test] + fn irreversible_completion_after_cancel_must_use_race_outcome() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + let mut accepted = draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Accepted, + ); + accepted.recoverability = Recoverability::Irreversible; + ring.record(accepted).unwrap(); + ring.mark_cancel_requested(invocation_id, 1_050).unwrap(); + + assert_eq!( + ring.finish(invocation_id, 50, AuditOutcome::Completed, None), + Err(AuditRingError::CompletedAfterCancelOutcomeRequired( + invocation_id + )) + ); + assert!( + ring.finish( + invocation_id, + 50, + AuditOutcome::CompletedAfterCancelRequest, + None, + ) + .is_ok() + ); + } + + #[test] + fn cancel_request_evidence_is_ordered_idempotent_and_only_for_inflight_records() { + let invocation_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(2).unwrap(); + ring.record(draft( + invocation_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Started, + )) + .unwrap(); + + assert_eq!( + ring.mark_cancel_requested(invocation_id, 999), + Err(AuditRingError::CancelRequestBeforeStart { + invocation_id, + started_at_ms: 1_000, + requested_at_ms: 999, + }) + ); + ring.mark_cancel_requested(invocation_id, 1_025).unwrap(); + let repeated = ring.mark_cancel_requested(invocation_id, 1_030).unwrap(); + assert_eq!(repeated.cancel_requested_at_ms, Some(1_025)); + ring.finish(invocation_id, 25, AuditOutcome::Cancelled, None) + .unwrap(); + assert_eq!( + ring.mark_cancel_requested(invocation_id, 1_040), + Err(AuditRingError::AlreadyTerminal(invocation_id)) + ); + } + + #[test] + fn completed_after_cancel_cannot_be_inserted_without_lifecycle_evidence() { + let invalid_id = InvocationId::new(); + let valid_id = InvocationId::new(); + let mut ring = ActionAuditRing::new(1).unwrap(); + let mut invalid = draft( + invalid_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::CompletedAfterCancelRequest, + ); + invalid.recoverability = Recoverability::Irreversible; + assert_eq!( + ring.record(invalid), + Err(AuditRingError::CompletedAfterCancelWithoutCancelRequest( + invalid_id + )) + ); + assert!(ring.is_empty()); + assert_eq!( + ring.record(draft( + valid_id, + AuditSpec::default().redact(&json!({})), + AuditOutcome::Denied, + )) + .unwrap() + .sequence, + 1 + ); + } + + #[test] + fn denied_decision_is_serialized_without_sensitive_arguments() { + let invocation_id = InvocationId::new(); + let mut denied = draft( + invocation_id, + AuditSpec::default().redact(&json!({"secret": "never"})), + AuditOutcome::Denied, + ); + denied.decision = PolicyDecision::Denied { + code: PolicyDenialCode::ReadOnlyMutation, + message: "Read Only cannot respond".into(), + }; + let record = ActionAuditRing::new(4).unwrap().record(denied).unwrap(); + let encoded = serde_json::to_string(&record).unwrap(); + assert!(encoded.contains("read_only_mutation")); + assert!(!encoded.contains("never")); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/catalog.rs b/apps/maple-agent/crates/maple-harness/src/catalog.rs new file mode 100644 index 00000000..48d9633d --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/catalog.rs @@ -0,0 +1,1017 @@ +//! The initial semantic action catalog, as data. +//! +//! The prototype's `app/src/harness/catalog.rs` (about 3,300 lines) declared +//! every descriptor next to its typed GPUI adapter and executor. That file +//! cannot survive without GPUI, but the *shape* of the catalog can: stable +//! dot-separated IDs, one family per application area, explicit effect and +//! authority per action, argument schemas that name stable identifiers (task +//! IDs, canonical roots, item IDs, setting keys) and never indices or entity +//! handles, and default Standard/Vim bindings declared on the descriptor. +//! +//! This module rebuilds a representative slice of section 11.1 of the design +//! document as a validated [`ActionRegistry`]. It is deliberately smaller +//! than the original (about forty actions instead of a few hundred) but it +//! covers every family and every authority class, so tests can prove the +//! contract rules: Human Only identity operations, the `app.quit` terminal +//! contract, the `permission.respond` Full Access contract, bindable actions +//! carrying adapters, and secret-bearing audit redaction. + +use serde_json::{Value, json}; + +use crate::{ + ActionDescriptor, ActionEffect, ActionId, ActionRegistry, AuditSpec, DefaultBinding, + InvocationPolicy, PreconditionDomainSelector, Recoverability, RegistryBuilder, + RegistryValidationError, SCHEMA_VERSION, SemanticContextPattern, ShortcutProfile, +}; + +/// One row of the catalog table before it becomes a descriptor. +struct Row { + id: &'static str, + label: &'static str, + description: &'static str, + category: &'static str, + arguments: Value, + effect: ActionEffect, + policy: InvocationPolicy, + recoverability: Recoverability, + audit: AuditSpec, + precondition: Option, + contexts: &'static [&'static str], + bindings: Vec<(ShortcutProfile, &'static str, &'static str)>, + terminal: bool, +} + +const NO_ARGS: fn() -> Value = || json!({"type": "object", "additionalProperties": false}); + +fn args(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": properties, + "required": required, + }) +} + +fn optional_task_arg() -> Value { + args(json!({"task_id": {"type": "string", "minLength": 1}}), &[]) +} + +fn task_arg() -> Value { + args( + json!({"task_id": {"type": "string", "minLength": 1}}), + &["task_id"], + ) +} + +fn rows() -> Vec { + use ActionEffect::*; + use InvocationPolicy::*; + use Recoverability::*; + let std = ShortcutProfile::Standard; + let vim = ShortcutProfile::Vim; + vec![ + // App / screens + Row { + id: "app.quit", + label: "Quit Maple", + description: "Commit an orderly shutdown after the accepted response is delivered.", + category: "App", + arguments: NO_ARGS(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![(std, "MapleApp", "cmd-q")], + terminal: true, + }, + Row { + id: "settings.open", + label: "Open Settings", + description: "Show the settings screen, parking the current chat.", + category: "App", + arguments: args( + json!({"section": {"type": "string", "enum": ["account", "appearance", "shortcuts", "programmability", "mcp"]}}), + &[], + ), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["section"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![ + (std, "MapleApp", "cmd-,"), + (vim, "MapleApp && app_vim_mode == normal", "space s"), + ], + terminal: false, + }, + Row { + id: "settings.close", + label: "Close Settings", + description: "Return to the parked chat screen.", + category: "App", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp && screen == settings"], + bindings: vec![(std, "MapleApp && screen == settings", "escape")], + terminal: false, + }, + Row { + id: "shortcuts.open", + label: "Open Shortcut Settings", + description: "Open the shortcut profile editor.", + category: "App", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![(std, "MapleApp", "cmd-k cmd-s")], + terminal: false, + }, + Row { + id: "palette.open", + label: "Command Palette", + description: "Open the root command palette.", + category: "App", + arguments: args(json!({"query": {"type": "string", "maxLength": 256}}), &[]), + effect: Navigate, + policy: HumanOnly, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["query"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![ + (std, "MapleApp", "cmd-shift-p"), + (vim, "MapleApp && app_vim_mode == normal", ":"), + ], + terminal: false, + }, + // Account / auth: Human Only + Row { + id: "account.sign_out", + label: "Sign Out", + description: "End the current Maple session on this device.", + category: "Account", + arguments: NO_ARGS(), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "account.delete", + label: "Delete Account", + description: "Begin the two-step account deletion flow.", + category: "Account", + arguments: NO_ARGS(), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "auth.submit_password", + label: "Sign In", + description: "Submit email and password on the login screen.", + category: "Account", + arguments: args( + json!({"email": {"type": "string"}, "password": {"type": "string"}}), + &["email", "password"], + ), + effect: ExternalEffect, + policy: HumanOnly, + recoverability: Irreversible, + audit: AuditSpec::secret_bearing(), + precondition: None, + contexts: &["MapleApp && screen == login"], + bindings: vec![], + terminal: false, + }, + // Tasks + Row { + id: "task.new", + label: "New Task", + description: "Create a task in the current project.", + category: "Tasks", + arguments: args(json!({"project_root": {"type": "string"}}), &[]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["project_root"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Project), + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-n"), + (vim, "Chat && app_vim_mode == normal", "space n"), + ], + terminal: false, + }, + Row { + id: "task.open", + label: "Open Task", + description: "Make a task the active conversation.", + category: "Tasks", + arguments: task_arg(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.rename", + label: "Rename Task", + description: "Set a task title.", + category: "Tasks", + arguments: args( + json!({"task_id": {"type": "string"}, "title": {"type": "string", "maxLength": 200}}), + &["task_id", "title"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.set_archived", + label: "Archive Task", + description: "Archive or unarchive a task; a setter, not a toggle.", + category: "Tasks", + arguments: args( + json!({"task_id": {"type": "string"}, "archived": {"type": "boolean"}}), + &["task_id", "archived"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "archived"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "task.focus_next", + label: "Next Task", + description: "Move the sidebar selection to the next task.", + category: "Tasks", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![ + (std, "Sidebar", "down"), + (vim, "Sidebar && app_vim_mode == normal", "j"), + ], + terminal: false, + }, + Row { + id: "task.focus_previous", + label: "Previous Task", + description: "Move the sidebar selection to the previous task.", + category: "Tasks", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![ + (std, "Sidebar", "up"), + (vim, "Sidebar && app_vim_mode == normal", "k"), + ], + terminal: false, + }, + // Projects + Row { + id: "project.choose", + label: "Open Project…", + description: "Pick a project directory; with no path the executor opens the native picker.", + category: "Projects", + arguments: args(json!({"path": {"type": "string"}}), &[]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["path"]).unwrap(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-o"), + (vim, "Chat && app_vim_mode == normal", "space p"), + ], + terminal: false, + }, + Row { + id: "project.set_trusted", + label: "Trust Project", + description: "Grant or revoke tool trust for a project root.", + category: "Projects", + arguments: args( + json!({"canonical_root": {"type": "string"}, "trusted": {"type": "boolean"}}), + &["canonical_root", "trusted"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["canonical_root", "trusted"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Project), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "project.set_collapsed", + label: "Collapse Project", + description: "Collapse or expand a sidebar project group.", + category: "Projects", + arguments: args( + json!({"canonical_root": {"type": "string"}, "collapsed": {"type": "boolean"}}), + &["canonical_root", "collapsed"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["canonical_root", "collapsed"]).unwrap(), + precondition: None, + contexts: &["Sidebar"], + bindings: vec![], + terminal: false, + }, + // Sidebar / transcript / regions + Row { + id: "sidebar.focus", + label: "Focus Sidebar", + description: "Move keyboard focus to the sidebar region.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-1"), + (vim, "Chat && app_vim_mode == normal", "g s"), + ], + terminal: false, + }, + Row { + id: "sidebar.set_collapsed", + label: "Toggle Sidebar", + description: "Show or hide the sidebar; a setter so callers state intent.", + category: "Navigation", + arguments: args(json!({"collapsed": {"type": "boolean"}}), &["collapsed"]), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["collapsed"]).unwrap(), + precondition: None, + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "transcript.focus", + label: "Focus Transcript", + description: "Move keyboard focus to the transcript region.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-2"), + (vim, "Chat && app_vim_mode == normal", "g t"), + ], + terminal: false, + }, + Row { + id: "transcript.focus_next", + label: "Next Message", + description: "Select the next transcript item.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "down"), + (vim, "Transcript && app_vim_mode == normal", "j"), + ], + terminal: false, + }, + Row { + id: "transcript.focus_previous", + label: "Previous Message", + description: "Select the previous transcript item.", + category: "Navigation", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "up"), + (vim, "Transcript && app_vim_mode == normal", "k"), + ], + terminal: false, + }, + Row { + id: "timeline.copy_item", + label: "Copy Message", + description: "Copy one timeline item by stable ID.", + category: "Transcript", + arguments: args( + json!({"task_id": {"type": "string"}, "item_id": {"type": "string"}}), + &["task_id", "item_id"], + ), + effect: Observe, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id", "item_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::TimelineItem), + contexts: &["Transcript"], + bindings: vec![], + terminal: false, + }, + Row { + id: "timeline.copy_selected", + label: "Copy Selected Message", + description: "Copy the selected transcript item; a Human Only contextual alias that resolves to timeline.copy_item.", + category: "Transcript", + arguments: NO_ARGS(), + effect: Observe, + policy: HumanOnly, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Transcript"], + bindings: vec![ + (std, "Transcript", "cmd-c"), + (vim, "Transcript && app_vim_mode == normal", "y y"), + ], + terminal: false, + }, + Row { + id: "timeline.set_tool_expanded", + label: "Expand Tool Call", + description: "Expand or collapse a tool call card.", + category: "Transcript", + arguments: args( + json!({"task_id": {"type": "string"}, "item_id": {"type": "string"}, "expanded": {"type": "boolean"}}), + &["task_id", "item_id", "expanded"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::allow_fields(["task_id", "item_id", "expanded"]).unwrap(), + precondition: Some(PreconditionDomainSelector::TimelineItem), + contexts: &["Transcript"], + bindings: vec![], + terminal: false, + }, + // Composer + Row { + id: "composer.focus", + label: "Focus Composer", + description: "Move keyboard focus to the composer.", + category: "Composer", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-3"), + (vim, "Chat && app_vim_mode == normal", "i"), + ], + terminal: false, + }, + Row { + id: "composer.set_text", + label: "Set Draft Text", + description: "Replace the draft text for a task.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "text": {"type": "string", "maxLength": 200000}}), + &["task_id", "text"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![], + terminal: false, + }, + Row { + id: "composer.send", + label: "Send", + description: "Send the current draft to the model.", + category: "Composer", + arguments: optional_task_arg(), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![(std, "Composer && input_role == composer", "enter")], + terminal: false, + }, + Row { + id: "composer.attach_files", + label: "Attach Files", + description: "Attach files by path; with no paths the executor opens the native picker.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "paths": {"type": "array", "items": {"type": "string"}, "maxItems": 32}}), + &[], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Draft), + contexts: &["Composer"], + bindings: vec![(std, "Composer", "cmd-shift-a")], + terminal: false, + }, + Row { + id: "composer.set_model", + label: "Set Model", + description: "Choose the model for a task.", + category: "Composer", + arguments: args( + json!({"task_id": {"type": "string"}, "model": {"type": "string"}}), + &["task_id", "model"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "model"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Composer"], + bindings: vec![], + terminal: false, + }, + // Runs / queue + Row { + id: "run.stop", + label: "Stop", + description: "Stop the active model run for a task.", + category: "Runs", + arguments: optional_task_arg(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![ + (std, "Chat", "cmd-."), + (vim, "Chat && app_vim_mode == normal", "ctrl-c"), + ], + terminal: false, + }, + Row { + id: "queue.remove", + label: "Remove Queued Message", + description: "Remove one queued follow-up by queue ID.", + category: "Runs", + arguments: args( + json!({"task_id": {"type": "string"}, "queue_id": {"type": "string"}}), + &["task_id", "queue_id"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["task_id", "queue_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + // Questions / permissions + Row { + id: "question.submit", + label: "Answer Question", + description: "Submit answers to a model question by request ID.", + category: "Requests", + arguments: args( + json!({"request_id": {"type": "string"}, "answers": {"type": "object"}}), + &["request_id", "answers"], + ), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["request_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Dialog && dialog == question"], + bindings: vec![], + terminal: false, + }, + Row { + id: "permission.respond", + label: "Respond to Permission", + description: "Allow or deny a tool permission request; an ordinary Full Access mutation by product decision.", + category: "Requests", + arguments: args( + json!({"request_id": {"type": "string"}, "decision": {"type": "string", "enum": ["allow_once", "allow_always", "deny"]}}), + &["request_id", "decision"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["request_id", "decision"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Target), + contexts: &["Dialog && dialog == permission"], + bindings: vec![], + terminal: false, + }, + // Settings + Row { + id: "settings.set_theme", + label: "Set Theme", + description: "Explicit setter for the theme preference.", + category: "Settings", + arguments: args( + json!({"theme": {"type": "string", "enum": ["system", "light", "dark"]}}), + &["theme"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["theme"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "settings.set_shortcut_profile", + label: "Set Shortcut Profile", + description: "Switch between the Standard and Vim profiles.", + category: "Settings", + arguments: args( + json!({"profile": {"type": "string", "enum": ["standard", "vim"]}}), + &["profile"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["profile"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + Row { + id: "mcp.set_enabled", + label: "Enable MCP Server", + description: "Enable or disable one configured MCP server.", + category: "Settings", + arguments: args( + json!({"server_id": {"type": "string"}, "enabled": {"type": "boolean"}}), + &["server_id", "enabled"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["server_id", "enabled"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + // Utilities + Row { + id: "ui.dismiss", + label: "Dismiss", + description: "Close the topmost overlay, menu, or dialog.", + category: "Utilities", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Overlay", "Dialog"], + bindings: vec![(std, "Overlay || Dialog", "escape")], + terminal: false, + }, + Row { + id: "ui.activate_selected", + label: "Activate Selection", + description: "Activate the selected semantic object; resolves to a concrete action that is re-authorized.", + category: "Utilities", + arguments: NO_ARGS(), + effect: Navigate, + policy: ControllerCallable, + recoverability: Ephemeral, + audit: AuditSpec::redact_all(), + precondition: None, + contexts: &["Chat", "Settings"], + bindings: vec![ + (std, "Sidebar || Transcript || Settings", "enter"), + ( + vim, + "(Sidebar || Transcript || Settings) && app_vim_mode == normal", + "enter", + ), + ], + terminal: false, + }, + Row { + id: "link.open", + label: "Open Link", + description: "Open a URL in the system browser.", + category: "Utilities", + arguments: args( + json!({"url": {"type": "string", "format": "uri"}}), + &["url"], + ), + effect: ExternalEffect, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["url"]).unwrap(), + precondition: None, + contexts: &["MapleApp"], + bindings: vec![], + terminal: false, + }, + // Code Mode: authority changes are Human Only, execution is ordinary + Row { + id: "code_mode.set_enabled", + label: "Set Python Code Mode", + description: "Turn the Developer Preview Code Mode on or off.", + category: "Code Mode", + arguments: args(json!({"enabled": {"type": "boolean"}}), &["enabled"]), + effect: MutateMaple, + policy: HumanOnly, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["enabled"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.set_controller_access", + label: "Set UI Controller Access", + description: "Set the model/Python controller mode: Off, Read Only, or Full Access.", + category: "Code Mode", + arguments: args( + json!({"access": {"type": "string", "enum": ["off", "read_only", "full_access"]}}), + &["access"], + ), + effect: MutateMaple, + policy: HumanOnly, + recoverability: Reversible, + audit: AuditSpec::allow_fields(["access"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Global), + contexts: &["MapleApp && screen == settings"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.execute", + label: "Run Python", + description: "Execute source in the task's persistent kernel.", + category: "Code Mode", + arguments: args( + json!({"task_id": {"type": "string"}, "source": {"type": "string", "maxLength": 262144}}), + &["task_id", "source"], + ), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + Row { + id: "code_mode.stop", + label: "Stop Python", + description: "Cancel the running execution and revoke its controller lease.", + category: "Code Mode", + arguments: task_arg(), + effect: MutateMaple, + policy: ControllerCallable, + recoverability: Irreversible, + audit: AuditSpec::allow_fields(["task_id"]).unwrap(), + precondition: Some(PreconditionDomainSelector::Task), + contexts: &["Chat"], + bindings: vec![], + terminal: false, + }, + ] +} + +fn descriptor(row: Row) -> ActionDescriptor { + let bindable = !row.bindings.is_empty(); + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(row.id).expect("catalog IDs follow the grammar"), + label: row.label.to_owned(), + description: row.description.to_owned(), + category: row.category.to_owned(), + argument_schema: row.arguments, + result_schema: json!({"type": "object"}), + contexts: row + .contexts + .iter() + .map(|c| SemanticContextPattern::parse(*c).expect("catalog contexts are valid")) + .collect(), + effect: row.effect, + invocation_policy: row.policy, + recoverability: row.recoverability, + audit: row.audit, + default_bindings: row + .bindings + .iter() + .map(|(profile, context, sequence)| DefaultBinding { + profile: *profile, + context: (*context).to_owned(), + sequence: (*sequence).to_owned(), + arguments: json!({}), + }) + .collect(), + precondition_domain: row.precondition, + bindable, + terminal_host_action: row.terminal, + } +} + +/// All descriptors of the initial catalog, unvalidated. +pub fn initial_descriptors() -> Vec { + rows().into_iter().map(descriptor).collect() +} + +/// The initial catalog as a validated registry. Every bindable action is +/// registered with an adapter, standing in for the one typed GPUI adapter the +/// application must provide per bindable action. +pub fn initial_registry() -> Result> { + let mut builder = RegistryBuilder::new(); + for descriptor in initial_descriptors() { + if descriptor.bindable { + builder.register_adapter(descriptor.id.clone()); + } + builder.register(descriptor); + } + builder.build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn initial_catalog_validates() { + let registry = initial_registry().unwrap_or_else(|errors| panic!("{errors:#?}")); + assert!(registry.len() >= 40); + } + + #[test] + fn every_family_in_section_11_1_is_represented() { + let registry = initial_registry().unwrap(); + let families: BTreeSet<&str> = registry + .iter() + .map(|(id, _)| id.as_str().split('.').next().unwrap()) + .collect(); + for family in [ + "app", + "settings", + "shortcuts", + "account", + "auth", + "task", + "project", + "sidebar", + "transcript", + "timeline", + "composer", + "run", + "queue", + "question", + "permission", + "mcp", + "ui", + "link", + "code_mode", + ] { + assert!(families.contains(family), "missing family {family}"); + } + } + + #[test] + fn identity_and_authority_actions_are_human_only() { + let registry = initial_registry().unwrap(); + for id in [ + "account.sign_out", + "account.delete", + "auth.submit_password", + "code_mode.set_enabled", + "code_mode.set_controller_access", + ] { + let descriptor = registry.descriptor(&ActionId::parse(id).unwrap()).unwrap(); + assert_eq!( + descriptor.invocation_policy, + InvocationPolicy::HumanOnly, + "{id}" + ); + } + let respond = registry + .descriptor(&ActionId::parse("permission.respond").unwrap()) + .unwrap(); + assert_eq!( + respond.invocation_policy, + InvocationPolicy::ControllerCallable + ); + let quit = registry + .descriptor(&ActionId::parse("app.quit").unwrap()) + .unwrap(); + assert!(quit.terminal_host_action); + } + + #[test] + fn secret_bearing_actions_redact_everything() { + let registry = initial_registry().unwrap(); + let login = registry + .descriptor(&ActionId::parse("auth.submit_password").unwrap()) + .unwrap(); + let redacted = login + .audit + .redact(&json!({"email": "a@b", "password": "hunter2"})); + assert!(redacted.is_empty()); + assert_eq!(redacted.redacted_field_count(), 2); + } + + #[test] + fn arguments_use_stable_identifiers_not_indices() { + for descriptor in initial_descriptors() { + let properties = descriptor.argument_schema["properties"] + .as_object() + .cloned() + .unwrap_or_default(); + for key in properties.keys() { + assert!( + !key.ends_with("_index") && !key.ends_with("_ix"), + "{}: {key}", + descriptor.id + ); + } + } + } + + #[test] + fn bindable_actions_have_adapters_and_vice_versa() { + let registry = initial_registry().unwrap(); + for (id, descriptor) in registry.iter() { + assert_eq!(descriptor.bindable, registry.has_adapter(id), "{id}"); + } + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/controller.rs b/apps/maple-agent/crates/maple-harness/src/controller.rs new file mode 100644 index 00000000..d0dd1ab8 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/controller.rs @@ -0,0 +1,470 @@ +//! The controller bridge between Code Mode / model programs and the host. +//! +//! In the prototype this seam was split across `crates/maple-code-mode/src/ +//! controller.rs` (the `UiControllerTransport` trait, request disposition and +//! delivery fences) and `app/src/harness/controller.rs` (a bounded GPUI +//! request/response bridge). The Python `maple_gpui` SDK serialised an +//! [`ActionCall`]-shaped request into the worker pipe; the kernel attached +//! provenance; the UI thread claimed the request, ran it through the one +//! [`ActionHost`](crate::ActionHost), and wrote the response frame back. +//! +//! This module is a from-scratch, runtime-free restatement of that bridge. +//! It keeps the three properties that made the prototype safe: +//! +//! - **Bounded admission.** The queue has a fixed capacity and rejects, +//! never blocks, when the UI has fallen behind. +//! - **One owner per request.** A [`RequestDisposition`] fence decides, +//! exactly once, whether the transport abandoned a request (timeout, drop, +//! Stop) or the UI claimed it. A claimed request always gets its exact +//! response; an abandoned one never enters the host. +//! - **Delivery before destruction.** A [`DeliveryBarrier`] marks when the +//! response frame reached the worker. Terminal host actions (`app.quit`) +//! commit shutdown only after the barrier succeeds, so Python observes +//! `accepted_terminal` instead of a broken pipe. +//! +//! Wire requests never carry authority: the bridge stores the origin the +//! kernel minted and the host reads its *current* policy at dispatch. + +use std::{ + collections::VecDeque, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, + }, +}; + +use thiserror::Error; + +use crate::{ActionCall, ActionResponse, ActionStatus, ControllerOrigin, UiControllerAccess}; + +/// Default bound on requests waiting for the UI thread. +pub const DEFAULT_CONTROLLER_QUEUE_CAPACITY: usize = 64; + +const PENDING: u8 = 0; +const UI_CLAIMED: u8 = 1; +const ABANDONED: u8 = 2; + +/// One-shot ownership decision for a request at the transport/UI seam. +/// +/// Process-local; never serialised into the worker protocol. +#[derive(Clone, Debug, Default)] +pub struct RequestDisposition(Arc); + +impl RequestDisposition { + /// Claim UI ownership immediately before dispatch. `false` means the + /// transport already abandoned the request; skip it without entering the + /// host. + pub fn try_claim_ui(&self) -> bool { + self.0 + .compare_exchange(PENDING, UI_CLAIMED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + /// Abandon only while still queued. `false` means the UI owns it and the + /// transport must wait for the exact response. + pub fn abandon_if_pending(&self) -> bool { + self.0 + .compare_exchange(PENDING, ABANDONED, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + pub fn is_ui_claimed(&self) -> bool { + self.0.load(Ordering::Acquire) == UI_CLAIMED + } + + pub fn is_abandoned(&self) -> bool { + self.0.load(Ordering::Acquire) == ABANDONED + } +} + +const DELIVERY_PENDING: u8 = 0; +const DELIVERY_SUCCEEDED: u8 = 1; +const DELIVERY_FAILED: u8 = 2; + +#[derive(Debug, Default)] +struct DeliveryState { + outcome: AtomicU8, + terminal_committed: AtomicBool, +} + +/// Completion fence for one response frame. +#[derive(Clone, Debug, Default)] +pub struct DeliveryBarrier(Arc); + +impl DeliveryBarrier { + /// The transport wrote the frame into the worker pipe. + pub fn mark_delivered(&self) -> bool { + self.0 + .outcome + .compare_exchange( + DELIVERY_PENDING, + DELIVERY_SUCCEEDED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// The transport could not write the frame (worker gone, pipe closed). + pub fn mark_failed(&self) -> bool { + self.0 + .outcome + .compare_exchange( + DELIVERY_PENDING, + DELIVERY_FAILED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub fn is_delivered(&self) -> bool { + self.0.outcome.load(Ordering::Acquire) == DELIVERY_SUCCEEDED + } + + pub fn is_settled(&self) -> bool { + self.0.outcome.load(Ordering::Acquire) != DELIVERY_PENDING + } + + /// Terminal host actions call this once the accepted-terminal frame is + /// delivered (or delivery definitively failed) to release shutdown. + pub fn commit_terminal(&self) -> Result<(), DeliveryError> { + if !self.is_settled() { + return Err(DeliveryError::TerminalBeforeDelivery); + } + self.0.terminal_committed.store(true, Ordering::Release); + Ok(()) + } + + pub fn is_terminal_committed(&self) -> bool { + self.0.terminal_committed.load(Ordering::Acquire) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum DeliveryError { + #[error( + "terminal shutdown may only commit after the response frame is delivered or delivery failed" + )] + TerminalBeforeDelivery, +} + +/// Monotonic per-bridge request identity (distinct from the host's +/// `InvocationId`, which is minted only when the UI claims the request). +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RequestId(u64); + +/// A request queued for the UI thread. +#[derive(Clone, Debug)] +pub struct QueuedRequest { + pub id: RequestId, + pub call: ActionCall, + pub origin: ControllerOrigin, + pub disposition: RequestDisposition, + pub delivery: DeliveryBarrier, +} + +/// A request the UI has claimed and must now run through the host. +#[derive(Debug)] +pub struct ClaimedRequest { + pub id: RequestId, + pub call: ActionCall, + pub origin: ControllerOrigin, + pub delivery: DeliveryBarrier, +} + +/// What the transport receives back for one request. +#[derive(Clone, Debug)] +pub struct Outcome { + pub id: RequestId, + pub response: ActionResponse, + pub delivery: DeliveryBarrier, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum SubmitError { + #[error("Maple UI Controller access is Off")] + ControllerOff, + #[error("controller request queue is full ({capacity})")] + QueueFull { capacity: usize }, + #[error("controller bridge is revoked; no new requests are admitted")] + Revoked, +} + +/// Bounded request/response bridge owned by the host process. +#[derive(Debug)] +pub struct ControllerBridge { + capacity: usize, + next_id: u64, + access: UiControllerAccess, + revoked: bool, + queue: VecDeque, + outcomes: Vec, +} + +impl Default for ControllerBridge { + fn default() -> Self { + Self::new(DEFAULT_CONTROLLER_QUEUE_CAPACITY) + } +} + +impl ControllerBridge { + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + next_id: 1, + access: UiControllerAccess::Off, + revoked: false, + queue: VecDeque::new(), + outcomes: Vec::new(), + } + } + + pub fn access(&self) -> UiControllerAccess { + self.access + } + + /// Mirrors a host authority change. Turning the controller Off revokes + /// every queued request (section 10.4); a later `ReadOnly`/`FullAccess` + /// re-arms the bridge for new programs. + pub fn set_access(&mut self, access: UiControllerAccess) -> usize { + self.access = access; + if access == UiControllerAccess::Off { + self.revoked = true; + self.revoke_queued() + } else { + self.revoked = false; + 0 + } + } + + /// Stop: abandon everything still queued. Claimed requests are not + /// touched; their exact response still flows back. + pub fn revoke_queued(&mut self) -> usize { + let mut revoked = 0; + for request in &self.queue { + if request.disposition.abandon_if_pending() { + revoked += 1; + } + } + self.queue.clear(); + revoked + } + + /// Transport side: enqueue a request minted by the kernel. + pub fn submit( + &mut self, + call: ActionCall, + origin: ControllerOrigin, + ) -> Result { + if self.revoked { + return Err(SubmitError::Revoked); + } + if self.access == UiControllerAccess::Off { + return Err(SubmitError::ControllerOff); + } + if self.queue.len() >= self.capacity { + return Err(SubmitError::QueueFull { + capacity: self.capacity, + }); + } + let request = QueuedRequest { + id: RequestId(self.next_id), + call, + origin, + disposition: RequestDisposition::default(), + delivery: DeliveryBarrier::default(), + }; + self.next_id += 1; + self.queue.push_back(request.clone()); + Ok(request) + } + + /// UI side: claim the next live request. Abandoned requests are dropped + /// silently; they never reach the host or the audit ring. + pub fn claim_next(&mut self) -> Option { + while let Some(request) = self.queue.pop_front() { + if request.origin.cancellation.is_cancelled() { + request.disposition.abandon_if_pending(); + continue; + } + if request.disposition.try_claim_ui() { + return Some(ClaimedRequest { + id: request.id, + call: request.call, + origin: request.origin, + delivery: request.delivery, + }); + } + } + None + } + + /// UI side: publish the host's response for a claimed request. + pub fn complete(&mut self, claimed: ClaimedRequest, response: ActionResponse) -> Outcome { + let outcome = Outcome { + id: claimed.id, + response, + delivery: claimed.delivery, + }; + self.outcomes.push(outcome.clone()); + outcome + } + + /// Transport side: drain responses ready to be framed to the worker. + pub fn take_outcomes(&mut self) -> Vec { + std::mem::take(&mut self.outcomes) + } + + pub fn queued_len(&self) -> usize { + self.queue.len() + } +} + +/// True when a response is the accepted-terminal frame that must be delivered +/// before the host may shut down. +pub fn is_terminal_frame(response: &ActionResponse) -> bool { + response.status == ActionStatus::AcceptedTerminal +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ActionBudget, ActionId, ExecutionId, InvocationActor, InvocationId, ProgramId}; + use serde_json::json; + use tokio_util::sync::CancellationToken; + + fn origin(cancellation: CancellationToken) -> ControllerOrigin { + ControllerOrigin { + actor: InvocationActor::Model, + source_task: None, + program_id: ProgramId::new(), + program_started_unix_ms: 0, + execution_id: ExecutionId::new(), + model_run_id: None, + kernel_generation: 1, + observed_policy_epoch: 0, + cancellation, + budget: ActionBudget::new(8).unwrap(), + } + } + + fn call() -> ActionCall { + ActionCall::new(ActionId::parse("task.list").unwrap()) + } + + fn bridge(capacity: usize) -> ControllerBridge { + let mut bridge = ControllerBridge::new(capacity); + bridge.set_access(UiControllerAccess::ReadOnly); + bridge + } + + #[test] + fn off_and_full_reject_without_blocking() { + let mut bridge = ControllerBridge::new(1); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::ControllerOff) + ); + bridge.set_access(UiControllerAccess::FullAccess); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::QueueFull { capacity: 1 }) + ); + } + + #[test] + fn abandoned_requests_never_reach_the_ui() { + let mut bridge = bridge(4); + let queued = bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert!(queued.disposition.abandon_if_pending()); + assert!(bridge.claim_next().is_none()); + assert!(!queued.disposition.try_claim_ui()); + } + + #[test] + fn claim_wins_the_race_and_gets_its_exact_response() { + let mut bridge = bridge(4); + let queued = bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + let claimed = bridge.claim_next().unwrap(); + assert!( + !queued.disposition.abandon_if_pending(), + "transport must now await the response" + ); + let response = ActionResponse::completed( + InvocationId::new(), + claimed.call.action_id.clone(), + json!({}), + Some(1), + ); + let outcome = bridge.complete(claimed, response.clone()); + assert_eq!(outcome.id, queued.id); + assert_eq!(bridge.take_outcomes().len(), 1); + assert_eq!(outcome.response, response); + } + + #[test] + fn cancelled_programs_are_skipped_at_claim_time() { + let mut bridge = bridge(4); + let token = CancellationToken::new(); + let queued = bridge.submit(call(), origin(token.clone())).unwrap(); + token.cancel(); + assert!(bridge.claim_next().is_none()); + assert!(queued.disposition.is_abandoned()); + } + + #[test] + fn turning_the_controller_off_revokes_the_queue() { + let mut bridge = bridge(4); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + bridge + .submit(call(), origin(CancellationToken::new())) + .unwrap(); + assert_eq!(bridge.set_access(UiControllerAccess::Off), 2); + assert_eq!(bridge.queued_len(), 0); + assert_eq!( + bridge + .submit(call(), origin(CancellationToken::new())) + .err(), + Some(SubmitError::Revoked) + ); + bridge.set_access(UiControllerAccess::ReadOnly); + assert!( + bridge + .submit(call(), origin(CancellationToken::new())) + .is_ok() + ); + } + + #[test] + fn terminal_shutdown_waits_for_delivery() { + let barrier = DeliveryBarrier::default(); + assert_eq!( + barrier.commit_terminal(), + Err(DeliveryError::TerminalBeforeDelivery) + ); + assert!(barrier.mark_delivered()); + assert!(!barrier.mark_failed(), "delivery settles exactly once"); + barrier.commit_terminal().unwrap(); + assert!(barrier.is_terminal_committed()); + let terminal = ActionResponse::accepted_terminal( + InvocationId::new(), + ActionId::parse("app.quit").unwrap(), + json!({}), + ); + assert!(is_terminal_frame(&terminal)); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/discovery.rs b/apps/maple-agent/crates/maple-harness/src/discovery.rs new file mode 100644 index 00000000..0b376687 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/discovery.rs @@ -0,0 +1,401 @@ +//! Discovery surfaces: the root command palette and which-key. +//! +//! Both existed in the prototype as GPUI overlays (`app/src/harness/ +//! palette.rs`, `which_key.rs`). Their value was never the rendering; it was +//! that they were *derived* from the same registry and resolved keymap that +//! drive dispatch, so a user could always find the action behind a button, +//! the key behind an action, and the reason an action was unavailable. This +//! module keeps those derivations as plain data structures: +//! +//! - [`PaletteIndex`] searches labels, IDs, descriptions, categories, and +//! current bindings, and ranks matches deterministically. Activation is +//! the caller's job: it must go through +//! [`ActionHost::invoke_palette`](crate::ActionHost::invoke_palette) so the +//! gesture carries `DirectUser`/`CommandPalette` provenance. +//! - [`WhichKeyTrie`] compiles effective bindings into a prefix trie so a +//! pending multi-stroke sequence can show its continuations, filtered to +//! the contexts that are currently active. + +use std::collections::BTreeMap; + +use crate::{ + ActionDescriptor, ActionId, ActionRegistry, Availability, ContextExpression, KeymapBinding, + ResolvedBinding, ResolvedBindingState, ResolvedKeymap, +}; + +/// One searchable palette entry. +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteEntry { + pub action_id: ActionId, + pub label: String, + pub description: String, + pub category: String, + /// Effective key sequences bound to this action in the active profile, + /// with the context each applies in. + pub bindings: Vec<(String, String)>, + /// Ex-style alias reachable from the Vim colon layer (`:settings`). + pub alias: Option, +} + +/// A ranked palette hit. +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteHit<'a> { + pub entry: &'a PaletteEntry, + pub score: u32, + pub availability: Availability, +} + +/// Searchable index over the registry and the active resolved keymap. +#[derive(Clone, Debug, Default)] +pub struct PaletteIndex { + entries: Vec, +} + +impl PaletteIndex { + /// Builds the index. `aliases` maps colon-layer words to action IDs + /// (`"settings" -> settings.open`). + pub fn build( + registry: &ActionRegistry, + keymap: Option<&ResolvedKeymap>, + aliases: &BTreeMap, + ) -> Self { + let mut by_action: BTreeMap<&ActionId, Vec<(String, String)>> = BTreeMap::new(); + if let Some(keymap) = keymap { + for binding in &keymap.bindings { + if binding.state != ResolvedBindingState::Effective { + continue; + } + if let Some(action_id) = binding.binding.action_id() { + by_action + .entry(action_id) + .or_default() + .push((binding.sequence.to_string(), binding.context.to_string())); + } + } + } + let alias_for: BTreeMap<&ActionId, &String> = + aliases.iter().map(|(a, id)| (id, a)).collect(); + let entries = registry + .iter() + .map(|(id, descriptor)| PaletteEntry { + action_id: id.clone(), + label: descriptor.label.clone(), + description: descriptor.description.clone(), + category: descriptor.category.clone(), + bindings: by_action.get(id).cloned().unwrap_or_default(), + alias: alias_for.get(id).map(|a| (*a).clone()), + }) + .collect(); + Self { entries } + } + + pub fn entries(&self) -> &[PaletteEntry] { + &self.entries + } + + /// Searches the index. `availability` lets the caller supply live + /// availability per action so disabled entries stay visible with their + /// reason instead of disappearing. + pub fn search<'a>( + &'a self, + query: &str, + availability: impl Fn(&ActionId) -> Availability, + ) -> Vec> { + let query = query.trim().to_ascii_lowercase(); + let mut hits: Vec> = self + .entries + .iter() + .filter_map(|entry| { + let score = score(entry, &query)?; + Some(PaletteHit { + entry, + score, + availability: availability(&entry.action_id), + }) + }) + .collect(); + // Available before disabled, then score, then stable ID order. + hits.sort_by(|a, b| { + b.availability + .is_available() + .cmp(&a.availability.is_available()) + .then(b.score.cmp(&a.score)) + .then(a.entry.action_id.cmp(&b.entry.action_id)) + }); + hits + } +} + +/// Deterministic ranking: exact alias or ID beats a label prefix, which beats +/// a word-prefix match, which beats a substring anywhere. +fn score(entry: &PaletteEntry, query: &str) -> Option { + if query.is_empty() { + return Some(1); + } + let id = entry.action_id.as_str(); + let label = entry.label.to_ascii_lowercase(); + if entry.alias.as_deref() == Some(query) || id == query { + return Some(1000); + } + if label.starts_with(query) || id.starts_with(query) { + return Some(800); + } + if label.split_whitespace().any(|w| w.starts_with(query)) + || id.split(['.', '_']).any(|w| w.starts_with(query)) + { + return Some(600); + } + if label.contains(query) || id.contains(query) { + return Some(400); + } + if entry.category.to_ascii_lowercase().contains(query) { + return Some(300); + } + if entry + .bindings + .iter() + .any(|(sequence, _)| sequence.contains(query)) + { + return Some(250); + } + if entry.description.to_ascii_lowercase().contains(query) { + return Some(200); + } + None +} + +/// A which-key continuation: the next stroke and what it leads to. +#[derive(Clone, Debug, PartialEq)] +pub struct Continuation { + pub stroke: String, + /// `Some` when this stroke completes a binding. + pub action: Option<(ActionId, String)>, + /// Number of longer bindings that still start with this stroke. + pub deeper: usize, + pub context: String, +} + +#[derive(Debug, Default)] +struct Node { + children: BTreeMap, + /// Complete bindings ending exactly here, keyed by context. + leaves: Vec<(ContextExpression, ActionId)>, +} + +/// Prefix trie over effective bindings, for pending-chord help. +#[derive(Debug, Default)] +pub struct WhichKeyTrie { + root: Node, +} + +impl WhichKeyTrie { + pub fn build(keymap: &ResolvedKeymap, registry: &ActionRegistry) -> Self { + let mut trie = Self::default(); + for binding in &keymap.bindings { + trie.insert(binding, registry); + } + trie + } + + fn insert(&mut self, binding: &ResolvedBinding, registry: &ActionRegistry) { + if binding.state != ResolvedBindingState::Effective { + return; + } + let KeymapBinding::Action { action_id, .. } = &binding.binding else { + return; + }; + if !registry.contains(action_id) { + return; + } + let mut node = &mut self.root; + for stroke in binding.sequence.strokes() { + node = node.children.entry(stroke.to_owned()).or_default(); + } + node.leaves + .push((binding.context.clone(), action_id.clone())); + } + + /// Continuations after `pending` strokes, restricted to bindings whose + /// context expression is one of `active_contexts` (already evaluated by + /// the caller against the focus/context stack). Labels come from the + /// registry so which-key shows human copy, not just IDs. + pub fn continuations( + &self, + pending: &[&str], + active_contexts: &[ContextExpression], + registry: &ActionRegistry, + ) -> Vec { + let mut node = &self.root; + for stroke in pending { + match node.children.get(*stroke) { + Some(next) => node = next, + None => return Vec::new(), + } + } + let is_active = |context: &ContextExpression| active_contexts.contains(context); + let mut out = Vec::new(); + for (stroke, child) in &node.children { + let leaf = child.leaves.iter().find(|(context, _)| is_active(context)); + let deeper = count_active_leaves(child, &is_active) - usize::from(leaf.is_some()); + if leaf.is_none() && deeper == 0 { + continue; + } + out.push(Continuation { + stroke: stroke.clone(), + action: leaf.map(|(_, id)| (id.clone(), label_for(registry, id))), + deeper, + context: leaf + .map(|(context, _)| context.to_string()) + .unwrap_or_default(), + }); + } + out + } +} + +fn count_active_leaves(node: &Node, is_active: &dyn Fn(&ContextExpression) -> bool) -> usize { + node.leaves.iter().filter(|(c, _)| is_active(c)).count() + + node + .children + .values() + .map(|child| count_active_leaves(child, is_active)) + .sum::() +} + +fn label_for(registry: &ActionRegistry, id: &ActionId) -> String { + registry + .descriptor(id) + .map(|d: &ActionDescriptor| d.label.clone()) + .unwrap_or_else(|| id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + DisabledReasonCode, KeymapDocument, ShortcutProfile, catalog::initial_registry, + resolve_keymap, + }; + + fn template(registry: &ActionRegistry, profile: ShortcutProfile) -> KeymapDocument { + // Build a template document from the catalog's default bindings. + let mut by_context: BTreeMap> = + BTreeMap::new(); + for (id, descriptor) in registry.iter() { + for binding in &descriptor.default_bindings { + if binding.profile == profile { + by_context + .entry(binding.context.clone()) + .or_default() + .insert( + binding.sequence.clone(), + serde_json::Value::String(id.to_string()), + ); + } + } + } + let entries: Vec = by_context + .into_iter() + .map( + |(context, bindings)| serde_json::json!({"context": context, "bindings": bindings}), + ) + .collect(); + KeymapDocument::parse(&serde_json::Value::Array(entries).to_string()).unwrap() + } + + fn keymap(profile: ShortcutProfile) -> (ActionRegistry, ResolvedKeymap) { + let registry = initial_registry().unwrap(); + let standard = template(®istry, ShortcutProfile::Standard); + let vim = template(®istry, ShortcutProfile::Vim); + let resolved = resolve_keymap( + profile, + &standard, + &vim, + &KeymapDocument::default(), + ®istry, + ) + .unwrap(); + (registry, resolved) + } + + #[test] + fn palette_ranks_alias_then_prefix_and_keeps_disabled_visible() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let mut aliases = BTreeMap::new(); + aliases.insert( + "settings".to_owned(), + ActionId::parse("settings.open").unwrap(), + ); + let index = PaletteIndex::build(®istry, Some(&resolved), &aliases); + let hits = index.search("settings", |_| Availability::Available); + assert_eq!(hits[0].entry.action_id.as_str(), "settings.open"); + assert_eq!(hits[0].score, 1000); + assert!( + hits.iter() + .any(|h| h.entry.action_id.as_str() == "settings.set_theme") + ); + + let disabled = DisabledReasonCode::parse("no_active_task").unwrap(); + let hits = index.search("task", |id| { + if id.as_str() == "task.rename" { + Availability::disabled(disabled.clone(), "No task is selected") + } else { + Availability::Available + } + }); + let rename = hits + .iter() + .position(|h| h.entry.action_id.as_str() == "task.rename") + .unwrap(); + assert!(!hits[rename].availability.is_available()); + assert!(hits[..rename].iter().all(|h| h.availability.is_available())); + } + + #[test] + fn palette_shows_current_bindings_from_the_resolved_keymap() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let index = PaletteIndex::build(®istry, Some(&resolved), &BTreeMap::new()); + let quit = index + .entries() + .iter() + .find(|e| e.action_id.as_str() == "app.quit") + .unwrap(); + assert_eq!( + quit.bindings, + vec![("cmd-q".to_owned(), "MapleApp".to_owned())] + ); + let by_key = index.search("cmd-q", |_| Availability::Available); + assert_eq!(by_key[0].entry.action_id.as_str(), "app.quit"); + } + + #[test] + fn which_key_lists_space_leader_continuations_in_vim() { + let (registry, resolved) = keymap(ShortcutProfile::Vim); + let trie = WhichKeyTrie::build(&resolved, ®istry); + let active = vec![ + ContextExpression::parse("MapleApp && app_vim_mode == normal").unwrap(), + ContextExpression::parse("Chat && app_vim_mode == normal").unwrap(), + ]; + let next = trie.continuations(&["space"], &active, ®istry); + let strokes: Vec<&str> = next.iter().map(|c| c.stroke.as_str()).collect(); + assert_eq!(strokes, vec!["n", "p", "s"]); + let settings = next.iter().find(|c| c.stroke == "s").unwrap(); + assert_eq!(settings.action.as_ref().unwrap().1, "Open Settings"); + assert_eq!(settings.deeper, 0); + // Sidebar-only bindings do not leak into a context that is not active. + let sidebar_only = trie.continuations(&[], &active, ®istry); + assert!(sidebar_only.iter().all(|c| c.stroke != "j")); + } + + #[test] + fn which_key_reports_prefixes_and_dead_ends() { + let (registry, resolved) = keymap(ShortcutProfile::Standard); + let trie = WhichKeyTrie::build(&resolved, ®istry); + let active = vec![ContextExpression::parse("MapleApp").unwrap()]; + let root = trie.continuations(&[], &active, ®istry); + let cmd_k = root.iter().find(|c| c.stroke == "cmd-k").unwrap(); + assert!(cmd_k.action.is_none()); + assert_eq!(cmd_k.deeper, 1); + assert!(trie.continuations(&["nope"], &active, ®istry).is_empty()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/host.rs b/apps/maple-agent/crates/maple-harness/src/host.rs new file mode 100644 index 00000000..65867d7f --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/host.rs @@ -0,0 +1,1006 @@ +//! The single action host, re-expressed without a window. +//! +//! In the original `maple-gpui` Developer Preview this lived in +//! `app/src/harness/host.rs` (about 2,400 lines) and was welded to GPUI: +//! typed GPUI actions, pointer callbacks, the command palette, application +//! Vim, and the Code Mode controller bridge all funnelled into one executor. +//! This module is a from-scratch, GPUI-free re-statement of that executor so +//! the *ideas* compile and are tested even though nothing renders: +//! +//! 1. **One path.** Every origin becomes an [`ActionCall`] plus a +//! host-minted [`TrustedInvocation`], and every call goes through +//! [`ActionHost::dispatch`]. There is no second business-logic entry point. +//! 2. **Descriptor first.** Unknown IDs, schema-invalid arguments, and +//! undeclared preconditions fail before any authority check. +//! 3. **Authority is host state.** Wire callers never carry authority. The +//! host attaches the *current* [`ProgrammabilityAuthority`] and policy +//! epoch; a lease minted under an older epoch fails closed. +//! 4. **Availability, then compare-and-set.** Executors report availability +//! against live state; call preconditions are checked against the +//! [`RevisionTracker`] so stale model programs cannot act on old state. +//! 5. **Generic actions are never authority shortcuts.** An executor may +//! answer [`ExecutorOutcome::Delegate`] with the concrete call it resolved +//! (for example `ui.activate_selected` resolving to `account.sign_out`); +//! the host re-enters the full pipeline for the concrete descriptor with a +//! derived invocation, so a Human Only target stays Human Only. +//! 6. **Audit is not optional.** Denials, starts, acceptances, and terminal +//! outcomes all land in the bounded, redacting [`ActionAuditRing`]. +//! 7. **Terminal host actions.** An action such as `app.quit` commits its +//! audit record and returns `AcceptedTerminal`, after which the host stops +//! admitting calls; orderly shutdown belongs to the embedding process. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::{ + ActionAuditDraft, ActionAuditRecord, ActionAuditRing, ActionBudget, ActionCall, + ActionDescriptor, ActionError, ActionErrorCode, ActionId, ActionRegistry, ActionResponse, + ActionStatus, AuditOutcome, Availability, DEFAULT_ACTION_AUDIT_CAPACITY, DirectUserIngress, + DirectUserProvenance, ExecutionId, InvocationActor, InvocationId, InvocationTransport, + PolicyDecision, ProgramId, ProgrammabilityAuthority, PythonCodeMode, RevisionChange, + RevisionDomain, RevisionTracker, RunId, StaleRevision, TaskIdentity, TrustedInvocation, + TrustedInvocationError, UiControllerAccess, +}; + +/// Default per-invocation admission budget for a direct-user gesture: one +/// gesture, one action, plus room for a single generic-to-concrete delegation. +pub const DIRECT_USER_ACTION_BUDGET: u32 = 2; + +/// Wall-clock source so tests can pin timestamps and durations. +pub trait HostClock: Send { + fn now_ms(&self) -> i64; +} + +/// System clock used by real hosts. +#[derive(Clone, Copy, Debug, Default)] +pub struct SystemClock; + +impl HostClock for SystemClock { + fn now_ms(&self) -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } +} + +/// Host-owned mutable state that executors may read and bump. +/// +/// Executors receive `&mut HostState` rather than the whole host so they can +/// never re-enter dispatch or touch the audit ring directly. +#[derive(Debug)] +pub struct HostState { + revisions: RevisionTracker, + authority: ProgrammabilityAuthority, + policy_epoch: u64, + shutdown_committed: bool, + /// Free-form application state for executors. The real host owned typed + /// GPUI models here; a JSON object is enough to preserve the contract. + pub model: Value, +} + +impl Default for HostState { + fn default() -> Self { + Self { + revisions: RevisionTracker::new(), + authority: ProgrammabilityAuthority::OFF, + policy_epoch: 0, + shutdown_committed: false, + model: json!({}), + } + } +} + +impl HostState { + pub fn revisions(&self) -> &RevisionTracker { + &self.revisions + } + + pub fn authority(&self) -> ProgrammabilityAuthority { + self.authority + } + + pub fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + pub fn is_shutdown_committed(&self) -> bool { + self.shutdown_committed + } + + /// Records a state change in one revision domain. Returns the new global + /// and domain revisions so the executor can report `state_revision`. + pub fn bump(&mut self, domain: RevisionDomain) -> RevisionChange { + self.revisions.bump(domain) + } +} + +/// What a policy change revoked. Section 10.4 of the design: policy is +/// revisioned, and every downgrade cancels the work it no longer permits. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PolicyRevocation { + pub new_policy_epoch: u64, + /// Python Code Mode turned off: kernels stop, executions cancel. + pub stop_kernels: bool, + /// Controller lost Full Access: pending mutation leases are void. + pub revoke_mutation_leases: bool, + /// Controller turned off: all SDK requests and event waits are void. + pub revoke_all_controller_requests: bool, +} + +/// Result of one executor run. +#[derive(Debug)] +pub enum ExecutorOutcome { + /// Synchronous completion. `bumped` is the revision change the executor + /// applied, if any, and is echoed back as `state_revision`. + Completed { + result: Value, + bumped: Option, + }, + /// The executor handed the work to a retained asynchronous operation. + /// The host records `Accepted`; the embedding process later calls + /// [`ActionHost::finish_accepted`]. + Accepted { + result: Value, + }, + /// A generic action resolved to a concrete one. The host re-dispatches + /// the concrete call under a derived invocation (final-action + /// reauthorization). + Delegate(ActionCall), + Failed(ActionError), +} + +/// The application-owned implementation of one semantic action. +pub trait Executor: Send { + /// Live availability for this call. Called on every dispatch after policy. + fn availability(&self, call: &ActionCall, state: &HostState) -> Availability { + let _ = (call, state); + Availability::Available + } + + fn execute( + &mut self, + call: &ActionCall, + invocation: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome; +} + +/// Provenance attached by the Code Mode kernel to a controller request. +/// +/// This mirrors the shape the Python SDK bridge supplied; none of these +/// fields grant authority. Authority is read from the host at dispatch time. +#[derive(Clone, Debug)] +pub struct ControllerOrigin { + pub actor: InvocationActor, + pub source_task: Option, + pub program_id: ProgramId, + pub program_started_unix_ms: u64, + pub execution_id: ExecutionId, + pub model_run_id: Option, + pub kernel_generation: u64, + /// Epoch the kernel observed when the program was admitted. If the host + /// epoch moved on, the call fails closed with `PolicyDenied`. + pub observed_policy_epoch: u64, + pub cancellation: CancellationToken, + pub budget: ActionBudget, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum HostBuildError { + #[error("executor registered for unknown action {0}")] + ExecutorForUnknownAction(ActionId), + #[error("action {0} has no executor")] + MissingExecutor(ActionId), + #[error("duplicate executor for action {0}")] + DuplicateExecutor(ActionId), +} + +/// Builder that enforces "every descriptor has exactly one executor". +pub struct ActionHostBuilder { + registry: ActionRegistry, + executors: BTreeMap>, + errors: Vec, + audit_capacity: usize, + clock: Box, +} + +impl ActionHostBuilder { + pub fn new(registry: ActionRegistry) -> Self { + Self { + registry, + executors: BTreeMap::new(), + errors: Vec::new(), + audit_capacity: DEFAULT_ACTION_AUDIT_CAPACITY, + clock: Box::new(SystemClock), + } + } + + pub fn executor(mut self, action_id: ActionId, executor: impl Executor + 'static) -> Self { + if !self.registry.contains(&action_id) { + self.errors + .push(HostBuildError::ExecutorForUnknownAction(action_id)); + return self; + } + if self.executors.contains_key(&action_id) { + self.errors + .push(HostBuildError::DuplicateExecutor(action_id)); + return self; + } + self.executors.insert(action_id, Box::new(executor)); + self + } + + pub fn audit_capacity(mut self, capacity: usize) -> Self { + self.audit_capacity = capacity; + self + } + + pub fn clock(mut self, clock: impl HostClock + 'static) -> Self { + self.clock = Box::new(clock); + self + } + + pub fn build(mut self) -> Result> { + for (id, _) in self.registry.iter() { + if !self.executors.contains_key(id) { + self.errors + .push(HostBuildError::MissingExecutor(id.clone())); + } + } + if !self.errors.is_empty() { + return Err(self.errors); + } + Ok(ActionHost { + registry: self.registry, + executors: self.executors, + ingress: DirectUserIngress::new_window(), + audit: ActionAuditRing::new(self.audit_capacity.max(1)).expect("capacity is non-zero"), + state: HostState::default(), + clock: self.clock, + }) + } +} + +/// The one action executor for an application instance. +pub struct ActionHost { + registry: ActionRegistry, + executors: BTreeMap>, + ingress: DirectUserIngress, + audit: ActionAuditRing, + state: HostState, + clock: Box, +} + +impl ActionHost { + pub fn registry(&self) -> &ActionRegistry { + &self.registry + } + + pub fn state(&self) -> &HostState { + &self.state + } + + pub fn state_mut(&mut self) -> &mut HostState { + &mut self.state + } + + pub fn audit(&self) -> &ActionAuditRing { + &self.audit + } + + /// The window-scoped direct-user ingress. Only physical input handlers + /// may hold this; it is how pointer/keyboard/palette gestures prove they + /// are human. + pub fn direct_user_ingress(&self) -> &DirectUserIngress { + &self.ingress + } + + /// Changes session authority. Every change bumps the policy epoch so any + /// invocation admitted earlier fails closed on its next action. + pub fn set_authority(&mut self, authority: ProgrammabilityAuthority) -> PolicyRevocation { + let previous = self.state.authority; + let next = authority.with_code_mode(authority.code_mode); + self.state.authority = next; + self.state.policy_epoch = self.state.policy_epoch.saturating_add(1); + PolicyRevocation { + new_policy_epoch: self.state.policy_epoch, + stop_kernels: previous.code_mode == PythonCodeMode::DeveloperPreview + && next.code_mode != PythonCodeMode::DeveloperPreview, + revoke_mutation_leases: previous.controller == UiControllerAccess::FullAccess + && next.controller != UiControllerAccess::FullAccess, + revoke_all_controller_requests: previous.controller != UiControllerAccess::Off + && next.controller == UiControllerAccess::Off, + } + } + + /// Mints a direct-user invocation from opaque physical provenance. This is + /// the *only* way to obtain `InvocationActor::DirectUser`. + pub fn direct_user_invocation( + &self, + provenance: DirectUserProvenance, + source_task: Option, + ) -> Result { + self.ingress.trusted_invocation( + InvocationId::new(), + provenance, + source_task, + self.state.authority.controller, + self.state.policy_epoch, + CancellationToken::new(), + ActionBudget::new(DIRECT_USER_ACTION_BUDGET).expect("non-zero budget"), + ) + } + + /// Mints a controller (model / user code) invocation. The controller + /// access recorded on the lease is the host's *current* value, never one + /// supplied by the caller; the caller's observed epoch is preserved so a + /// stale program fails closed at admission. + pub fn controller_invocation( + &self, + origin: &ControllerOrigin, + ) -> Result { + let invocation = TrustedInvocation::new( + InvocationId::new(), + origin.source_task.clone(), + Some(origin.program_id), + origin.model_run_id.clone(), + Some(origin.execution_id), + Some(origin.kernel_generation), + origin.actor, + InvocationTransport::Python, + self.state.authority.controller, + origin.observed_policy_epoch, + origin.cancellation.clone(), + origin.budget.clone(), + )?; + Ok(invocation.with_program_started_unix_ms(origin.program_started_unix_ms)) + } + + /// Convenience: dispatch a pointer gesture (button, menu item). + pub fn invoke_pointer(&mut self, call: ActionCall) -> ActionResponse { + let provenance = self.ingress.pointer(); + match self.direct_user_invocation(provenance, None) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + /// Convenience: dispatch a command-palette activation. + pub fn invoke_palette(&mut self, call: ActionCall) -> ActionResponse { + let provenance = self.ingress.command_palette(); + match self.direct_user_invocation(provenance, None) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + /// Convenience: dispatch a Code Mode / model controller request. + pub fn invoke_controller( + &mut self, + call: ActionCall, + origin: &ControllerOrigin, + ) -> ActionResponse { + match self.controller_invocation(origin) { + Ok(invocation) => self.dispatch(call, invocation), + Err(error) => self.wiring_failure(call, error), + } + } + + fn wiring_failure(&self, call: ActionCall, error: TrustedInvocationError) -> ActionResponse { + ActionResponse::failed( + InvocationId::new(), + call.action_id, + ActionError::new(ActionErrorCode::PolicyDenied, error.to_string()) + .expect("policy_denied needs no reason code"), + ) + .expect("failed response is well-formed") + } + + /// The one execution path. + pub fn dispatch(&mut self, call: ActionCall, invocation: TrustedInvocation) -> ActionResponse { + let invocation_id = invocation.invocation_id(); + let action_id = call.action_id.clone(); + + if self.state.shutdown_committed { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::Failed, + "host shutdown committed; no new actions admitted", + ); + } + + // 1. Descriptor and structural validation come before authority. + let Some(descriptor) = self.registry.descriptor(&action_id).cloned() else { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::UnknownAction, + "unknown action", + ); + }; + if let Err(error) = descriptor.validate_arguments(&call.arguments) { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::InvalidArguments, + error.to_string(), + ); + } + if let Err(error) = descriptor.validate_precondition(&call) { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::InvalidArguments, + error.to_string(), + ); + } + + // 2. Lease freshness and budget. A stale epoch or cancelled program + // never reaches an executor. + if let Err(error) = invocation.admit_call(self.state.policy_epoch) { + let code = if invocation.cancellation().is_cancelled() { + ActionErrorCode::Cancelled + } else { + ActionErrorCode::PolicyDenied + }; + return self.fail(invocation_id, action_id, code, error.to_string()); + } + + // 3. Authority matrix (actor x transport x controller mode x effect). + let decision = invocation.authorize(&descriptor); + let started_ms = self.clock.now_ms(); + if let PolicyDecision::Denied { message, .. } = &decision { + self.record_denied( + &descriptor, + &call, + &invocation, + decision.clone(), + started_ms, + ); + return self.fail( + invocation_id, + action_id, + ActionErrorCode::PolicyDenied, + message.clone(), + ); + } + + // 4. Live availability, then compare-and-set precondition. + let executor = self + .executors + .get(&action_id) + .expect("builder guarantees an executor per descriptor"); + if let Availability::Disabled { code, message } = executor.availability(&call, &self.state) + { + let error = ActionError::unavailable(code, message); + return ActionResponse::failed(invocation_id, action_id, error) + .expect("unavailable response is well-formed"); + } + if let Some(precondition) = &call.precondition + && let Err(StaleRevision { + domain, + expected, + actual, + }) = self + .state + .revisions + .check(&precondition.domain, precondition.target_revision) + { + return self.fail( + invocation_id, + action_id, + ActionErrorCode::StaleTarget, + format!( + "{} changed from revision {expected} to {actual}", + domain_label(&domain) + ), + ); + } + + // 5. Audit "started", then run the executor. + if let Err(error) = self.audit.record(ActionAuditDraft { + invocation_id, + program_id: invocation.program_id(), + model_run_id: invocation.model_run_id(), + timestamp_ms: started_ms, + actor: invocation.actor().into(), + transport: invocation.transport(), + controller_access: invocation.controller_access(), + policy_epoch: invocation.policy_epoch(), + action_id: action_id.clone(), + target: call.target.clone(), + arguments: descriptor.audit.redact(&call.arguments), + effect: descriptor.effect, + recoverability: descriptor.recoverability, + decision, + outcome: AuditOutcome::Started, + error_code: None, + }) { + // Audit backpressure: the ring is full of in-flight records. Fail + // the call rather than lose the trail. + return self.fail( + invocation_id, + action_id, + ActionErrorCode::Failed, + error.to_string(), + ); + } + + let executor = self + .executors + .get_mut(&action_id) + .expect("builder guarantees an executor per descriptor"); + let outcome = executor.execute(&call, &invocation, &mut self.state); + let duration_ms = (self.clock.now_ms() - started_ms).max(0) as u64; + + match outcome { + ExecutorOutcome::Completed { result, bumped } => { + if descriptor.terminal_host_action { + self.state.shutdown_committed = true; + let _ = self.audit.finish( + invocation_id, + duration_ms, + AuditOutcome::AcceptedTerminal, + None, + ); + return ActionResponse::accepted_terminal(invocation_id, action_id, result); + } + let outcome = if descriptor.recoverability == crate::Recoverability::Irreversible + && self.cancel_was_requested(invocation_id) + { + AuditOutcome::CompletedAfterCancelRequest + } else { + AuditOutcome::Completed + }; + let _ = self.audit.finish(invocation_id, duration_ms, outcome, None); + ActionResponse::completed( + invocation_id, + action_id, + result, + Some( + bumped.map_or(self.state.revisions.state_revision(), |c| c.state_revision), + ), + ) + } + ExecutorOutcome::Accepted { result } => { + let _ = self.audit.accept(invocation_id); + ActionResponse::accepted(invocation_id, action_id, result) + } + ExecutorOutcome::Delegate(concrete) => { + // Final-action reauthorization: close the generic record and + // run the concrete call through the whole pipeline again. + let _ = + self.audit + .finish(invocation_id, duration_ms, AuditOutcome::Completed, None); + let derived = invocation.derived(InvocationId::new()); + let mut response = self.dispatch(concrete, derived); + // Report under the caller's invocation so the SDK correlates + // the answer with the request it made. + response.invocation_id = invocation_id; + response + } + ExecutorOutcome::Failed(error) => { + let outcome = if error.code == ActionErrorCode::Cancelled { + AuditOutcome::Cancelled + } else { + AuditOutcome::Failed + }; + let _ = self + .audit + .finish(invocation_id, duration_ms, outcome, Some(error.code)); + ActionResponse::failed(invocation_id, action_id, error) + .expect("executor errors carry the required metadata") + } + } + } + + /// Completes an operation the executor previously returned as `Accepted`. + pub fn finish_accepted( + &mut self, + invocation_id: InvocationId, + outcome: AuditOutcome, + error_code: Option, + ) -> Option { + let started = self + .audit + .records() + .rev() + .find(|record| record.invocation_id == invocation_id) + .map(|record| record.timestamp_ms)?; + let duration_ms = (self.clock.now_ms() - started).max(0) as u64; + self.audit + .finish(invocation_id, duration_ms, outcome, error_code) + .ok() + } + + /// Records a user or policy cancel request against an in-flight invocation. + pub fn request_cancel(&mut self, invocation_id: InvocationId) -> Option { + let now = self.clock.now_ms(); + self.audit.mark_cancel_requested(invocation_id, now).ok() + } + + fn cancel_was_requested(&self, invocation_id: InvocationId) -> bool { + self.audit + .records() + .rev() + .find(|record| record.invocation_id == invocation_id) + .is_some_and(|record| record.cancel_requested_at_ms.is_some()) + } + + fn record_denied( + &mut self, + descriptor: &ActionDescriptor, + call: &ActionCall, + invocation: &TrustedInvocation, + decision: PolicyDecision, + timestamp_ms: i64, + ) { + let _ = self.audit.record(ActionAuditDraft { + invocation_id: invocation.invocation_id(), + program_id: invocation.program_id(), + model_run_id: invocation.model_run_id(), + timestamp_ms, + actor: invocation.actor().into(), + transport: invocation.transport(), + controller_access: invocation.controller_access(), + policy_epoch: invocation.policy_epoch(), + action_id: descriptor.id.clone(), + target: call.target.clone(), + arguments: descriptor.audit.redact(&call.arguments), + effect: descriptor.effect, + recoverability: descriptor.recoverability, + decision, + outcome: AuditOutcome::Denied, + error_code: Some(ActionErrorCode::PolicyDenied), + }); + } + + fn fail( + &self, + invocation_id: InvocationId, + action_id: ActionId, + code: ActionErrorCode, + message: impl Into, + ) -> ActionResponse { + let error = ActionError::new(code, message) + .expect("non-unavailable error codes need no reason code"); + ActionResponse::failed(invocation_id, action_id, error) + .expect("failed response is well-formed") + } +} + +fn domain_label(domain: &RevisionDomain) -> String { + serde_json::to_value(domain) + .ok() + .and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_owned)) + .unwrap_or_else(|| "revision domain".to_owned()) +} + +impl ActionResponse { + /// True when the host admitted the call and the executor ran to a + /// terminal or accepted state. + pub fn is_success(&self) -> bool { + matches!( + self.status, + ActionStatus::Accepted | ActionStatus::AcceptedTerminal | ActionStatus::Completed + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + ActionEffect, AuditSpec, InvocationPolicy, Recoverability, RegistryBuilder, SCHEMA_VERSION, + }; + + struct FixedClock; + impl HostClock for FixedClock { + fn now_ms(&self) -> i64 { + 1_700_000_000_000 + } + } + + fn descriptor(id: &str, effect: ActionEffect, policy: InvocationPolicy) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: id.to_owned(), + description: format!("test action {id}"), + category: "test".into(), + argument_schema: json!({"type": "object", "additionalProperties": false}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect, + invocation_policy: policy, + recoverability: Recoverability::Reversible, + audit: AuditSpec::redact_all(), + default_bindings: Vec::new(), + precondition_domain: Some(crate::PreconditionDomainSelector::Global), + bindable: false, + terminal_host_action: false, + } + } + + /// Bumps the global revision and records that it ran. + struct Counter; + impl Executor for Counter { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome { + let bumped = state.bump(RevisionDomain::Global); + let runs = state.model["runs"].as_u64().unwrap_or(0) + 1; + state.model["runs"] = json!(runs); + ExecutorOutcome::Completed { + result: json!({"runs": runs}), + bumped: Some(bumped), + } + } + } + + /// `ui.activate_selected`: resolves to whatever is selected. + struct ActivateSelected; + impl Executor for ActivateSelected { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + state: &mut HostState, + ) -> ExecutorOutcome { + let selected = state.model["selected"] + .as_str() + .unwrap_or("chat.toggle_sidebar"); + ExecutorOutcome::Delegate(ActionCall::new(ActionId::parse(selected).unwrap())) + } + } + + struct Quit; + impl Executor for Quit { + fn execute( + &mut self, + _: &ActionCall, + _: &TrustedInvocation, + _: &mut HostState, + ) -> ExecutorOutcome { + ExecutorOutcome::Completed { + result: json!({}), + bumped: None, + } + } + } + + fn host() -> ActionHost { + let mut registry = RegistryBuilder::new(); + registry.register(descriptor( + "chat.toggle_sidebar", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + )); + registry.register(descriptor( + "task.list", + ActionEffect::Observe, + InvocationPolicy::ControllerCallable, + )); + registry.register(descriptor( + "account.sign_out", + ActionEffect::ExternalEffect, + InvocationPolicy::HumanOnly, + )); + registry.register(descriptor( + "ui.activate_selected", + ActionEffect::Navigate, + InvocationPolicy::ControllerCallable, + )); + let mut quit = descriptor( + "app.quit", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + ); + quit.terminal_host_action = true; + registry.register(quit); + let registry = registry.build().unwrap(); + ActionHostBuilder::new(registry) + .executor(ActionId::parse("chat.toggle_sidebar").unwrap(), Counter) + .executor(ActionId::parse("task.list").unwrap(), Counter) + .executor(ActionId::parse("account.sign_out").unwrap(), Counter) + .executor( + ActionId::parse("ui.activate_selected").unwrap(), + ActivateSelected, + ) + .executor(ActionId::parse("app.quit").unwrap(), Quit) + .clock(FixedClock) + .build() + .unwrap() + } + + fn call(id: &str) -> ActionCall { + ActionCall::new(ActionId::parse(id).unwrap()) + } + + fn model_origin(host: &ActionHost) -> ControllerOrigin { + ControllerOrigin { + actor: InvocationActor::Model, + source_task: None, + program_id: ProgramId::new(), + program_started_unix_ms: 1, + execution_id: ExecutionId::new(), + model_run_id: None, + kernel_generation: 1, + observed_policy_epoch: host.state().policy_epoch(), + cancellation: CancellationToken::new(), + budget: ActionBudget::new(16).unwrap(), + } + } + + fn full_access() -> ProgrammabilityAuthority { + ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::FullAccess, + ) + .unwrap() + } + + #[test] + fn builder_requires_one_executor_per_descriptor() { + let mut registry = RegistryBuilder::new(); + registry.register(descriptor( + "chat.toggle_sidebar", + ActionEffect::MutateMaple, + InvocationPolicy::ControllerCallable, + )); + let errors = ActionHostBuilder::new(registry.build().unwrap()) + .build() + .err() + .unwrap(); + assert_eq!( + errors, + vec![HostBuildError::MissingExecutor( + ActionId::parse("chat.toggle_sidebar").unwrap() + )] + ); + } + + #[test] + fn direct_user_bypasses_controller_mode_but_not_availability() { + let mut host = host(); + assert_eq!(host.state().authority(), ProgrammabilityAuthority::OFF); + let response = host.invoke_pointer(call("chat.toggle_sidebar")); + assert_eq!(response.status, ActionStatus::Completed); + assert_eq!(response.state_revision, Some(1)); + assert_eq!(host.audit().len(), 1); + assert_eq!(host.audit().recent(1)[0].outcome, AuditOutcome::Completed); + } + + #[test] + fn controller_off_denies_model_and_audits_the_denial() { + let mut host = host(); + let origin = model_origin(&host); + let response = host.invoke_controller(call("task.list"), &origin); + assert_eq!(response.status, ActionStatus::Failed); + assert_eq!(response.error.unwrap().code, ActionErrorCode::PolicyDenied); + assert_eq!(host.audit().recent(1)[0].outcome, AuditOutcome::Denied); + } + + #[test] + fn read_only_allows_observe_and_denies_mutation() { + let mut host = host(); + host.set_authority( + ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::ReadOnly, + ) + .unwrap(), + ); + let origin = model_origin(&host); + assert!( + host.invoke_controller(call("task.list"), &origin) + .is_success() + ); + let denied = host.invoke_controller(call("chat.toggle_sidebar"), &origin); + assert_eq!(denied.error.unwrap().code, ActionErrorCode::PolicyDenied); + } + + #[test] + fn human_only_stays_human_only_through_generic_delegation() { + let mut host = host(); + host.set_authority(full_access()); + host.state_mut().model["selected"] = json!("account.sign_out"); + let origin = model_origin(&host); + let response = host.invoke_controller(call("ui.activate_selected"), &origin); + assert_eq!( + response.error.as_ref().unwrap().code, + ActionErrorCode::PolicyDenied + ); + assert_eq!(response.action_id.as_str(), "account.sign_out"); + // The same gesture from a human succeeds. + let human = host.invoke_pointer(call("ui.activate_selected")); + assert_eq!(human.status, ActionStatus::Completed); + } + + #[test] + fn policy_change_bumps_epoch_and_stale_leases_fail_closed() { + let mut host = host(); + host.set_authority(full_access()); + let origin = model_origin(&host); + assert!( + host.invoke_controller(call("chat.toggle_sidebar"), &origin) + .is_success() + ); + let revocation = host.set_authority(ProgrammabilityAuthority::OFF); + assert!( + revocation.stop_kernels + && revocation.revoke_all_controller_requests + && revocation.revoke_mutation_leases + ); + let stale = host.invoke_controller(call("task.list"), &origin); + assert_eq!(stale.error.unwrap().code, ActionErrorCode::PolicyDenied); + } + + #[test] + fn stale_precondition_is_rejected_before_execution() { + let mut host = host(); + host.invoke_pointer(call("chat.toggle_sidebar")); + let stale = call("chat.toggle_sidebar").with_precondition(crate::ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 0, + }); + let response = host.invoke_pointer(stale); + assert_eq!(response.error.unwrap().code, ActionErrorCode::StaleTarget); + let fresh = call("chat.toggle_sidebar").with_precondition(crate::ActionPrecondition { + domain: RevisionDomain::Global, + target_revision: 1, + }); + assert!(host.invoke_pointer(fresh).is_success()); + } + + #[test] + fn unknown_and_invalid_calls_fail_before_authority() { + let mut host = host(); + let unknown = host.invoke_pointer(call("nope.missing")); + assert_eq!(unknown.error.unwrap().code, ActionErrorCode::UnknownAction); + let invalid = host.invoke_pointer(call("task.list").with_arguments(json!({"extra": 1}))); + assert_eq!( + invalid.error.unwrap().code, + ActionErrorCode::InvalidArguments + ); + assert!( + host.audit().is_empty(), + "structural failures are not audited actions" + ); + } + + #[test] + fn terminal_host_action_stops_admission() { + let mut host = host(); + let response = host.invoke_pointer(call("app.quit")); + assert_eq!(response.status, ActionStatus::AcceptedTerminal); + assert!(host.state().is_shutdown_committed()); + let after = host.invoke_pointer(call("task.list")); + assert_eq!(after.status, ActionStatus::Failed); + } + + #[test] + fn direct_user_budget_bounds_a_single_gesture() { + let host = host(); + let invocation = host + .direct_user_invocation(host.direct_user_ingress().pointer(), None) + .unwrap(); + assert_eq!( + invocation.action_budget().limit(), + DIRECT_USER_ACTION_BUDGET + ); + let foreign = DirectUserIngress::new_window(); + assert_eq!( + host.direct_user_invocation(foreign.pointer(), None).err(), + Some(TrustedInvocationError::WrongWindowProvenance) + ); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/keymap.rs b/apps/maple-agent/crates/maple-harness/src/keymap.rs new file mode 100644 index 00000000..c6bd1126 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/keymap.rs @@ -0,0 +1,1042 @@ +use std::{ + collections::{BTreeMap, HashMap}, + fmt, + str::FromStr, +}; + +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, de, + de::{MapAccess, Visitor}, +}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ActionId, ActionRegistry, SchemaValidationError, ShortcutProfile}; + +const MAX_KEY_SEQUENCE_BYTES: usize = 128; +const MAX_KEY_STROKES: usize = 8; +const MAX_CONTEXT_BYTES: usize = 512; + +/// A syntactically validated space-separated GPUI keystroke sequence. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct KeySequence(String); + +impl KeySequence { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_key_sequence(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn strokes(&self) -> impl Iterator { + self.0.split(' ') + } + + pub fn stroke_count(&self) -> usize { + self.strokes().count() + } + + pub fn is_prefix_of(&self, other: &Self) -> bool { + let this = self.strokes().collect::>(); + let other = other.strokes().collect::>(); + this.len() < other.len() && other.starts_with(&this) + } +} + +impl fmt::Display for KeySequence { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for KeySequence { + type Err = KeySequenceError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for KeySequence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum KeySequenceError { + #[error("key sequence is empty")] + Empty, + #[error("key sequence exceeds {MAX_KEY_SEQUENCE_BYTES} bytes")] + TooLong, + #[error("key sequence must use single ASCII spaces with no surrounding whitespace")] + NonCanonicalWhitespace, + #[error("key sequence exceeds the {MAX_KEY_STROKES}-stroke limit")] + TooManyStrokes, + #[error("keystroke {stroke:?} contains a control or non-ASCII character")] + InvalidStroke { stroke: String }, +} + +fn validate_key_sequence(value: &str) -> Result<(), KeySequenceError> { + if value.is_empty() { + return Err(KeySequenceError::Empty); + } + if value.len() > MAX_KEY_SEQUENCE_BYTES { + return Err(KeySequenceError::TooLong); + } + let strokes = value.split_whitespace().collect::>(); + if strokes.join(" ") != value { + return Err(KeySequenceError::NonCanonicalWhitespace); + } + if strokes.len() > MAX_KEY_STROKES { + return Err(KeySequenceError::TooManyStrokes); + } + for stroke in strokes { + if stroke.is_empty() + || !stroke.is_ascii() + || stroke.chars().any(|character| character.is_ascii_control()) + { + return Err(KeySequenceError::InvalidStroke { + stroke: stroke.to_owned(), + }); + } + } + Ok(()) +} + +/// Validated GPUI-compatible boolean context expression. This core validator +/// checks the portable lexical/balance contract; the app still passes it to +/// GPUI's parser before atomically installing a complete map. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ContextExpression(String); + +impl ContextExpression { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_context_expression(&value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn specificity(&self) -> usize { + self.0.split("&&").count() + } +} + +impl fmt::Display for ContextExpression { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ContextExpression { + type Err = ContextExpressionError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } +} + +impl<'de> Deserialize<'de> for ContextExpression { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum ContextExpressionError { + #[error("key context expression is empty")] + Empty, + #[error("key context expression exceeds {MAX_CONTEXT_BYTES} bytes")] + TooLong, + #[error("key context expression has surrounding whitespace or control characters")] + NonCanonicalWhitespace, + #[error( + "key context expression contains unsupported character {character:?} at byte {byte_index}" + )] + InvalidCharacter { byte_index: usize, character: char }, + #[error("key context expression has unbalanced parentheses")] + UnbalancedParentheses, + #[error("key context expression contains an incomplete boolean/equality operator")] + IncompleteOperator, +} + +fn validate_context_expression(value: &str) -> Result<(), ContextExpressionError> { + if value.is_empty() { + return Err(ContextExpressionError::Empty); + } + if value.len() > MAX_CONTEXT_BYTES { + return Err(ContextExpressionError::TooLong); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(ContextExpressionError::NonCanonicalWhitespace); + } + let mut depth = 0usize; + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_alphanumeric() + || matches!( + character, + '_' | '.' + | '-' + | ' ' + | '(' + | ')' + | '&' + | '|' + | '!' + | '=' + | '>' + | '<' + | '~' + | '"' + | '?' + )) + { + return Err(ContextExpressionError::InvalidCharacter { + byte_index, + character, + }); + } + match character { + '(' => depth += 1, + ')' => { + depth = depth + .checked_sub(1) + .ok_or(ContextExpressionError::UnbalancedParentheses)?; + } + _ => {} + } + } + if depth != 0 { + return Err(ContextExpressionError::UnbalancedParentheses); + } + + validate_context_operators(value.as_bytes())?; + Ok(()) +} + +fn validate_context_operators(bytes: &[u8]) -> Result<(), ContextExpressionError> { + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'&' => { + if bytes.get(index + 1) != Some(&b'&') || bytes.get(index + 2) == Some(&b'&') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'|' => { + if bytes.get(index + 1) != Some(&b'|') || bytes.get(index + 2) == Some(&b'|') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'=' => { + if bytes.get(index + 1) != Some(&b'=') || bytes.get(index + 2) == Some(&b'=') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + b'!' if bytes.get(index + 1) == Some(&b'=') => { + if bytes.get(index + 2) == Some(&b'=') { + return Err(ContextExpressionError::IncompleteOperator); + } + index += 2; + } + _ => index += 1, + } + } + Ok(()) +} + +/// Zed-style binding value: action ID, `[action_id, arguments]`, or `null`. +#[derive(Clone, Debug, PartialEq)] +pub enum KeymapBinding { + Disabled, + Action { + action_id: ActionId, + arguments: Value, + }, +} + +impl KeymapBinding { + pub fn action(action_id: ActionId) -> Self { + Self::Action { + action_id, + arguments: Value::Object(Default::default()), + } + } + + pub fn parameterized(action_id: ActionId, arguments: Value) -> Self { + Self::Action { + action_id, + arguments, + } + } + + pub fn action_id(&self) -> Option<&ActionId> { + match self { + Self::Disabled => None, + Self::Action { action_id, .. } => Some(action_id), + } + } + + pub fn arguments(&self) -> Option<&Value> { + match self { + Self::Disabled => None, + Self::Action { arguments, .. } => Some(arguments), + } + } +} + +impl Serialize for KeymapBinding { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Disabled => serializer.serialize_none(), + Self::Action { + action_id, + arguments, + } if arguments + .as_object() + .is_some_and(|object| object.is_empty()) => + { + action_id.serialize(serializer) + } + Self::Action { + action_id, + arguments, + } => (action_id, arguments).serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for KeymapBinding { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(Self::Disabled); + }; + match value { + Value::String(action_id) => Ok(Self::action( + ActionId::parse(action_id).map_err(de::Error::custom)?, + )), + Value::Array(values) if values.len() == 2 => { + let mut values = values.into_iter(); + let action_id = values + .next() + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or_else(|| de::Error::custom("binding action ID must be a string"))?; + let arguments = values.next().expect("array length was checked"); + if !arguments.is_object() { + return Err(de::Error::custom( + "parameterized binding arguments must be an object", + )); + } + Ok(Self::parameterized( + ActionId::parse(action_id).map_err(de::Error::custom)?, + arguments, + )) + } + _ => Err(de::Error::custom( + "binding must be an action ID, [action ID, argument object], or null", + )), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeymapEntry { + pub context: ContextExpression, + #[serde(deserialize_with = "deserialize_bindings")] + pub bindings: BTreeMap, +} + +fn deserialize_bindings<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct BindingsVisitor; + + impl<'de> Visitor<'de> for BindingsVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a keymap bindings object with unique key sequences") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut bindings = BTreeMap::new(); + while let Some((sequence, binding)) = map.next_entry::()? { + if bindings.insert(sequence.clone(), binding).is_some() { + return Err(de::Error::custom(format_args!( + "duplicate binding sequence {:?}", + sequence.as_str() + ))); + } + } + Ok(bindings) + } + } + + deserializer.deserialize_map(BindingsVisitor) +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct KeymapDocument(pub Vec); + +impl KeymapDocument { + pub fn parse(input: &str) -> Result { + serde_json::from_str(input).map_err(KeymapParseError::Json) + } + + pub fn validate(&self, registry: &ActionRegistry) -> Result<(), Vec> { + let mut errors = Vec::new(); + for (entry_index, entry) in self.0.iter().enumerate() { + for (sequence, binding) in &entry.bindings { + let KeymapBinding::Action { + action_id, + arguments, + } = binding + else { + continue; + }; + let Some(descriptor) = registry.descriptor(action_id) else { + errors.push(KeymapValidationError::UnknownAction { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + }); + continue; + }; + if !descriptor.bindable { + errors.push(KeymapValidationError::ActionNotBindable { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + }); + } + if let Err(source) = descriptor.validate_arguments(arguments) { + errors.push(KeymapValidationError::InvalidArguments { + entry_index, + sequence: sequence.clone(), + action_id: action_id.clone(), + source, + }); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[derive(Debug, Error)] +pub enum KeymapParseError { + #[error("invalid keymap JSON: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Clone, Debug, Error, PartialEq)] +pub enum KeymapValidationError { + #[error("entry {entry_index} sequence {sequence} references unknown action {action_id}")] + UnknownAction { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + }, + #[error("entry {entry_index} sequence {sequence} references non-bindable action {action_id}")] + ActionNotBindable { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + }, + #[error( + "entry {entry_index} sequence {sequence} has invalid arguments for {action_id}: {source}" + )] + InvalidArguments { + entry_index: usize, + sequence: KeySequence, + action_id: ActionId, + source: SchemaValidationError, + }, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum BindingSource { + Template(ShortcutProfile), + User, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ResolvedBindingState { + Effective, + Shadowed, + Disabled, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedBinding { + pub context: ContextExpression, + pub sequence: KeySequence, + pub binding: KeymapBinding, + pub source: BindingSource, + pub source_entry: usize, + pub state: ResolvedBindingState, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum KeymapConflictKind { + Exact, + Prefix, + Shadowed, + Possible, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeymapConflict { + pub kind: KeymapConflictKind, + pub left: usize, + pub right: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedKeymap { + pub profile: ShortcutProfile, + pub bindings: Vec, + pub conflicts: Vec, +} + +/// Resolves exactly one selected first-party template, then user overrides. +pub fn resolve_keymap( + profile: ShortcutProfile, + standard: &KeymapDocument, + vim: &KeymapDocument, + user: &KeymapDocument, + registry: &ActionRegistry, +) -> Result> { + let template = match profile { + ShortcutProfile::Standard => standard, + ShortcutProfile::Vim => vim, + }; + template.validate(registry)?; + user.validate(registry)?; + + let mut bindings = Vec::new(); + flatten_bindings(template, BindingSource::Template(profile), &mut bindings); + flatten_bindings(user, BindingSource::User, &mut bindings); + + let mut latest_by_exact_key: HashMap<(ContextExpression, KeySequence), usize> = HashMap::new(); + let mut conflicts = Vec::new(); + for index in 0..bindings.len() { + let exact_key = ( + bindings[index].context.clone(), + bindings[index].sequence.clone(), + ); + if let Some(previous) = latest_by_exact_key.insert(exact_key, index) { + bindings[previous].state = ResolvedBindingState::Shadowed; + conflicts.push(KeymapConflict { + kind: KeymapConflictKind::Shadowed, + left: previous, + right: index, + }); + } + if matches!(bindings[index].binding, KeymapBinding::Disabled) { + bindings[index].state = ResolvedBindingState::Disabled; + } + } + + for left in 0..bindings.len() { + if bindings[left].state != ResolvedBindingState::Effective { + continue; + } + for right in (left + 1)..bindings.len() { + if bindings[right].state != ResolvedBindingState::Effective { + continue; + } + let same_sequence = bindings[left].sequence == bindings[right].sequence; + let prefix = bindings[left] + .sequence + .is_prefix_of(&bindings[right].sequence) + || bindings[right] + .sequence + .is_prefix_of(&bindings[left].sequence); + if !same_sequence && !prefix { + continue; + } + let overlap = context_overlap(&bindings[left].context, &bindings[right].context); + match overlap { + ContextOverlap::Disjoint => {} + ContextOverlap::Exact => conflicts.push(KeymapConflict { + kind: if same_sequence { + KeymapConflictKind::Exact + } else { + KeymapConflictKind::Prefix + }, + left, + right, + }), + ContextOverlap::Possible => conflicts.push(KeymapConflict { + kind: KeymapConflictKind::Possible, + left, + right, + }), + } + } + } + + Ok(ResolvedKeymap { + profile, + bindings, + conflicts, + }) +} + +fn flatten_bindings( + document: &KeymapDocument, + source: BindingSource, + output: &mut Vec, +) { + for (entry_index, entry) in document.0.iter().enumerate() { + for (sequence, binding) in &entry.bindings { + output.push(ResolvedBinding { + context: entry.context.clone(), + sequence: sequence.clone(), + binding: binding.clone(), + source, + source_entry: entry_index, + state: ResolvedBindingState::Effective, + }); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ContextOverlap { + Exact, + Disjoint, + Possible, +} + +fn context_overlap(left: &ContextExpression, right: &ContextExpression) -> ContextOverlap { + if left == right { + return ContextOverlap::Exact; + } + let Some(left_equalities) = equality_constraints(left.as_str()) else { + return ContextOverlap::Possible; + }; + let Some(right_equalities) = equality_constraints(right.as_str()) else { + return ContextOverlap::Possible; + }; + for (key, left_value) in &left_equalities { + if let Some(right_value) = right_equalities.get(key) + && left_value != right_value + { + return ContextOverlap::Disjoint; + } + } + ContextOverlap::Possible +} + +fn equality_constraints(context: &str) -> Option> { + // Only prove disjointness for a flat conjunction. OR, negation, + // grouping, and descendant expressions require a real predicate solver; + // treating a textual equality inside one of them as unconditional would + // suppress a genuine possible conflict. + if context.contains("||") || context.contains('!') || context.contains(['(', ')', '>']) { + return None; + } + + let mut equalities = HashMap::new(); + for term in context.split("&&") { + let mut parts = term.split("=="); + let key = parts.next()?; + let Some(value) = parts.next() else { + continue; + }; + if parts.next().is_some() { + return None; + } + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return None; + } + match equalities.entry(key.to_owned()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(value.to_owned()); + } + std::collections::hash_map::Entry::Occupied(entry) if entry.get() == value => {} + std::collections::hash_map::Entry::Occupied(_) => return None, + } + } + Some(equalities) +} + +/// Keeps an active last-known-good map when a hand-edited reload is invalid. +#[derive(Clone, Debug)] +pub struct LastKnownGoodKeymap { + active: ResolvedKeymap, + last_error: Option>, +} + +impl LastKnownGoodKeymap { + pub fn new(active: ResolvedKeymap) -> Self { + Self { + active, + last_error: None, + } + } + + pub fn active(&self) -> &ResolvedKeymap { + &self.active + } + + pub fn last_error(&self) -> Option<&[KeymapValidationError]> { + self.last_error.as_deref() + } + + pub fn apply(&mut self, candidate: Result>) -> bool { + match candidate { + Ok(candidate) => { + self.active = candidate; + self.last_error = None; + true + } + Err(errors) => { + self.last_error = Some(errors); + false + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ + ActionDescriptor, ActionEffect, AuditSpec, InvocationPolicy, Recoverability, + RegistryBuilder, SCHEMA_VERSION, + }; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: id.into(), + description: format!("Description for {id}"), + category: "Test".into(), + argument_schema: json!({ + "type": "object", + "properties": {"steer": {"type": "boolean"}}, + "additionalProperties": false + }), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + precondition_domain: None, + effect: ActionEffect::Navigate, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Ephemeral, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + bindable: true, + terminal_host_action: false, + } + } + + fn registry() -> ActionRegistry { + let mut builder = RegistryBuilder::new(); + for id in ["task.new", "composer.send", "transcript.focus_next"] { + let action_id = ActionId::parse(id).unwrap(); + builder.register(descriptor(id)); + builder.register_adapter(action_id); + } + builder.build().unwrap() + } + + #[test] + fn zed_style_wire_format_round_trips_string_parameterized_and_null() { + let document = KeymapDocument::parse( + r#"[ + { + "context": "MapleApp && profile == vim", + "bindings": { + "j": "transcript.focus_next", + "cmd-k": null, + "ctrl-enter": ["composer.send", {"steer": true}] + } + } + ]"#, + ) + .unwrap(); + assert_eq!(document.0.len(), 1); + assert!(matches!( + document.0[0].bindings[&KeySequence::parse("cmd-k").unwrap()], + KeymapBinding::Disabled + )); + assert!(document.validate(®istry()).is_ok()); + let encoded = serde_json::to_value(&document).unwrap(); + assert!(encoded[0]["bindings"]["cmd-k"].is_null()); + assert_eq!(encoded[0]["bindings"]["j"], "transcript.focus_next"); + } + + #[test] + fn malformed_context_operator_runs_are_rejected() { + for context in [ + "MapleApp &&& profile == vim", + "MapleApp ||| profile == vim", + "MapleApp & profile == vim", + "MapleApp | profile == vim", + "profile = vim", + "profile === vim", + "profile !== vim", + ] { + assert_eq!( + ContextExpression::parse(context).unwrap_err(), + ContextExpressionError::IncompleteOperator, + "context {context:?} should be rejected" + ); + } + + for context in [ + "MapleApp && profile == vim", + "MapleApp || profile != vim", + "!MapleApp", + "Pane > Editor", + "vim_operator == >", + ] { + assert!( + ContextExpression::parse(context).is_ok(), + "context {context:?} should pass portable lexical validation" + ); + } + } + + #[test] + fn duplicate_binding_keys_are_rejected_during_json_parse() { + let error = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"j":"task.new","j":"composer.send"}}]"#, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("duplicate binding sequence \"j\"") + ); + } + + #[test] + fn unknown_fields_ids_and_arguments_are_rejected() { + assert!( + KeymapDocument::parse(r#"[{"context":"MapleApp","unexpected":true,"bindings":{}}]"#) + .is_err() + ); + let unknown = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"x":"unknown.action"}}]"#) + .unwrap(); + assert!(matches!( + unknown.validate(®istry()).unwrap_err()[0], + KeymapValidationError::UnknownAction { .. } + )); + let invalid_arguments = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"x":["composer.send",{"steer":"yes"}]}}]"#, + ) + .unwrap(); + assert!(matches!( + invalid_arguments.validate(®istry()).unwrap_err()[0], + KeymapValidationError::InvalidArguments { .. } + )); + } + + #[test] + fn selected_templates_replace_instead_of_stack() { + let standard = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new"}}]"#) + .unwrap(); + let vim = KeymapDocument::parse( + r#"[{"context":"MapleApp && profile == vim","bindings":{"space s n":"task.new"}}]"#, + ) + .unwrap(); + let user = KeymapDocument::default(); + let standard_resolved = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &vim, + &user, + ®istry(), + ) + .unwrap(); + assert!( + standard_resolved + .bindings + .iter() + .any(|binding| binding.sequence.as_str() == "cmd-n") + ); + assert!( + standard_resolved + .bindings + .iter() + .all(|binding| binding.sequence.as_str() != "space s n") + ); + let vim_resolved = + resolve_keymap(ShortcutProfile::Vim, &standard, &vim, &user, ®istry()).unwrap(); + assert!( + vim_resolved + .bindings + .iter() + .all(|binding| binding.sequence.as_str() != "cmd-n") + ); + } + + #[test] + fn user_override_and_null_shadow_template_at_equal_context() { + let standard = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new","cmd-k":"task.new"}}]"#, + ) + .unwrap(); + let user = KeymapDocument::parse( + r#"[{"context":"MapleApp","bindings":{"cmd-n":"composer.send","cmd-k":null}}]"#, + ) + .unwrap(); + let resolved = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &user, + ®istry(), + ) + .unwrap(); + assert_eq!( + resolved + .bindings + .iter() + .filter(|binding| binding.state == ResolvedBindingState::Shadowed) + .count(), + 2 + ); + assert!(resolved.bindings.iter().any(|binding| { + binding.sequence.as_str() == "cmd-k" && binding.state == ResolvedBindingState::Disabled + })); + } + + #[test] + fn conflict_detection_classifies_prefix_and_possible_overlap() { + let template = KeymapDocument::parse( + r#"[ + {"context":"MapleApp && profile == vim","bindings":{"g":"task.new","g g":"task.new"}}, + {"context":"MapleApp && region == transcript","bindings":{"g":"transcript.focus_next"}} + ]"#, + ) + .unwrap(); + let resolved = resolve_keymap( + ShortcutProfile::Vim, + &KeymapDocument::default(), + &template, + &KeymapDocument::default(), + ®istry(), + ) + .unwrap(); + assert!( + resolved + .conflicts + .iter() + .any(|conflict| conflict.kind == KeymapConflictKind::Prefix) + ); + assert!( + resolved + .conflicts + .iter() + .any(|conflict| conflict.kind == KeymapConflictKind::Possible) + ); + } + + #[test] + fn overlap_proof_is_conservative_for_complex_predicates() { + let standard = ContextExpression::parse("profile == standard").unwrap(); + + assert_eq!( + context_overlap( + &ContextExpression::parse("profile == vim").unwrap(), + &standard, + ), + ContextOverlap::Disjoint + ); + + for context in [ + "profile == vim || region == transcript", + "!(profile == vim)", + "(profile == vim)", + "Pane > profile == vim", + ] { + assert_eq!( + context_overlap(&ContextExpression::parse(context).unwrap(), &standard), + ContextOverlap::Possible, + "complex context {context:?} must not produce a false disjointness proof" + ); + } + } + + #[test] + fn invalid_reload_preserves_last_known_good() { + let standard = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"cmd-n":"task.new"}}]"#) + .unwrap(); + let valid = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &KeymapDocument::default(), + ®istry(), + ) + .unwrap(); + let mut active = LastKnownGoodKeymap::new(valid); + let old_bindings = active.active().bindings.clone(); + let invalid = + KeymapDocument::parse(r#"[{"context":"MapleApp","bindings":{"x":"missing.action"}}]"#) + .unwrap(); + let candidate = resolve_keymap( + ShortcutProfile::Standard, + &standard, + &KeymapDocument::default(), + &invalid, + ®istry(), + ); + assert!(!active.apply(candidate)); + assert_eq!(active.active().bindings, old_bindings); + assert!(active.last_error().is_some()); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/lib.rs b/apps/maple-agent/crates/maple-harness/src/lib.rs new file mode 100644 index 00000000..f1bbf303 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/lib.rs @@ -0,0 +1,32 @@ +//! GPUI-free core types and validation for Maple's programmable harness. +//! +//! This crate deliberately contains no action executors and no UI objects. The +//! application owns those capabilities; this crate defines the wire contract, +//! policy, registry validation, semantic projection primitives, and bounded +//! audit/event stores shared by every transport. + +#![forbid(unsafe_code)] + +pub mod action; +pub mod audit; +pub mod catalog; +pub mod controller; +pub mod discovery; +pub mod host; +pub mod keymap; +pub mod policy; +pub mod registry; +pub mod semantic; + +pub use action::*; +pub use audit::*; +pub use controller::*; +pub use discovery::*; +pub use host::*; +pub use keymap::*; +pub use policy::*; +pub use registry::*; +pub use semantic::*; + +/// Initial wire schema version for Developer Preview contracts. +pub const SCHEMA_VERSION: u16 = 1; diff --git a/apps/maple-agent/crates/maple-harness/src/policy.rs b/apps/maple-agent/crates/maple-harness/src/policy.rs new file mode 100644 index 00000000..2c351b23 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/policy.rs @@ -0,0 +1,994 @@ +use std::sync::{ + Arc, + atomic::{AtomicU32, Ordering}, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::{ + ActionDescriptor, ActionEffect, ExecutionId, InvocationId, ProgramId, RunId, TaskIdentity, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationPolicy { + ControllerCallable, + HumanOnly, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationActor { + DirectUser, + Model, + UserCode, + Internal, +} + +impl InvocationActor { + pub const ALL: [Self; 4] = [ + Self::DirectUser, + Self::Model, + Self::UserCode, + Self::Internal, + ]; +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvocationTransport { + Pointer, + Keybinding, + CommandPalette, + Python, + Macro, + GeneratedUi, + Internal, +} + +impl InvocationTransport { + pub const ALL: [Self; 7] = [ + Self::Pointer, + Self::Keybinding, + Self::CommandPalette, + Self::Python, + Self::Macro, + Self::GeneratedUi, + Self::Internal, + ]; +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiControllerAccess { + #[default] + Off, + ReadOnly, + FullAccess, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PythonCodeMode { + #[default] + Off, + DeveloperPreview, +} + +/// Normalized, session-only runtime authority. +#[derive(Clone, Copy, Debug, Default, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProgrammabilityAuthority { + pub code_mode: PythonCodeMode, + pub controller: UiControllerAccess, +} + +impl<'de> Deserialize<'de> for ProgrammabilityAuthority { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct WireAuthority { + code_mode: PythonCodeMode, + controller: UiControllerAccess, + } + + let wire = WireAuthority::deserialize(deserializer)?; + Self::new(wire.code_mode, wire.controller).map_err(serde::de::Error::custom) + } +} + +impl ProgrammabilityAuthority { + pub const OFF: Self = Self { + code_mode: PythonCodeMode::Off, + controller: UiControllerAccess::Off, + }; + + pub fn new( + code_mode: PythonCodeMode, + controller: UiControllerAccess, + ) -> Result { + let authority = Self { + code_mode, + controller, + }; + authority.validate()?; + Ok(authority) + } + + pub fn validate(self) -> Result<(), AuthorityTransitionError> { + if self.code_mode == PythonCodeMode::Off && self.controller != UiControllerAccess::Off { + return Err(AuthorityTransitionError::ControllerRequiresCodeMode); + } + Ok(()) + } + + /// Changes Code Mode and atomically revokes controller access when turning it off. + pub fn with_code_mode(mut self, code_mode: PythonCodeMode) -> Self { + self.code_mode = code_mode; + if code_mode == PythonCodeMode::Off { + self.controller = UiControllerAccess::Off; + } + self + } + + pub fn with_controller( + mut self, + controller: UiControllerAccess, + ) -> Result { + if self.code_mode == PythonCodeMode::Off && controller != UiControllerAccess::Off { + return Err(AuthorityTransitionError::ControllerRequiresCodeMode); + } + self.controller = controller; + Ok(self) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum AuthorityTransitionError { + #[error("Read Only or Full Access controller authority requires Developer Preview Code Mode")] + ControllerRequiresCodeMode, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyDenialCode { + HumanOnly, + ControllerOff, + ReadOnlyMutation, + InvalidDirectUserOrigin, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "decision", rename_all = "snake_case")] +pub enum PolicyDecision { + Allowed, + Denied { + code: PolicyDenialCode, + message: String, + }, +} + +impl PolicyDecision { + pub const fn is_allowed(&self) -> bool { + matches!(self, Self::Allowed) + } + + pub fn denial_code(&self) -> Option { + match self { + Self::Allowed => None, + Self::Denied { code, .. } => Some(*code), + } + } +} + +/// Applies the complete actor/transport/controller matrix. +/// +/// Only the three physical/direct-user tuples bypass controller authority. +/// `Internal` has no implicit privilege; it is evaluated exactly like any +/// other non-direct origin. +pub fn authorize( + invocation_policy: InvocationPolicy, + effect: ActionEffect, + actor: InvocationActor, + transport: InvocationTransport, + controller_access: UiControllerAccess, +) -> PolicyDecision { + let is_direct_user_origin = is_allowed_direct_user_origin(actor, transport); + + if actor == InvocationActor::DirectUser && !is_direct_user_origin { + return PolicyDecision::Denied { + code: PolicyDenialCode::InvalidDirectUserOrigin, + message: "Direct-user authority requires a trusted pointer, physical keybinding, or command-palette activation".into(), + }; + } + + if invocation_policy == InvocationPolicy::HumanOnly { + return if is_direct_user_origin { + PolicyDecision::Allowed + } else { + PolicyDecision::Denied { + code: PolicyDenialCode::HumanOnly, + message: "This action requires direct human activation".into(), + } + }; + } + + if is_direct_user_origin { + return PolicyDecision::Allowed; + } + + match controller_access { + UiControllerAccess::Off => PolicyDecision::Denied { + code: PolicyDenialCode::ControllerOff, + message: "Maple UI Controller access is Off".into(), + }, + UiControllerAccess::ReadOnly => match effect { + ActionEffect::Observe | ActionEffect::Navigate => PolicyDecision::Allowed, + ActionEffect::MutateMaple | ActionEffect::ExternalEffect => PolicyDecision::Denied { + code: PolicyDenialCode::ReadOnlyMutation, + message: + "Read Only controller access cannot mutate Maple or cause external effects" + .into(), + }, + }, + UiControllerAccess::FullAccess => PolicyDecision::Allowed, + } +} + +pub fn is_allowed_direct_user_origin( + actor: InvocationActor, + transport: InvocationTransport, +) -> bool { + actor == InvocationActor::DirectUser + && matches!( + transport, + InvocationTransport::Pointer + | InvocationTransport::Keybinding + | InvocationTransport::CommandPalette + ) +} + +/// A shared, bounded admission counter for one program and all derived calls. +#[derive(Clone, Debug)] +pub struct ActionBudget { + inner: Arc, +} + +#[derive(Debug)] +struct ActionBudgetInner { + limit: u32, + consumed: AtomicU32, +} + +impl ActionBudget { + pub fn new(limit: u32) -> Result { + if limit == 0 { + return Err(ActionBudgetError::ZeroLimit); + } + Ok(Self { + inner: Arc::new(ActionBudgetInner { + limit, + consumed: AtomicU32::new(0), + }), + }) + } + + pub fn limit(&self) -> u32 { + self.inner.limit + } + + pub fn consumed(&self) -> u32 { + self.inner.consumed.load(Ordering::Acquire) + } + + pub fn remaining(&self) -> u32 { + self.limit().saturating_sub(self.consumed()) + } + + pub fn try_consume(&self) -> Result { + let mut consumed = self.inner.consumed.load(Ordering::Acquire); + loop { + if consumed >= self.inner.limit { + return Err(ActionBudgetError::Exhausted { + limit: self.inner.limit, + }); + } + match self.inner.consumed.compare_exchange_weak( + consumed, + consumed + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Ok(consumed + 1), + Err(current) => consumed = current, + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ActionBudgetError { + #[error("action budget must be greater than zero")] + ZeroLimit, + #[error("program action budget of {limit} calls is exhausted")] + Exhausted { limit: u32 }, +} + +/// Window-scoped issuer held by the trusted UI host. Tokens minted by this +/// value are opaque, non-serializable, and move-only. +#[derive(Clone, Debug)] +pub struct DirectUserIngress { + issuer_id: DirectUserIssuerId, +} + +/// Opaque identity for the one direct-user issuer registered with an action +/// host. This value is deliberately non-serializable: it is host wiring, not a +/// capability a controller request may supply. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct DirectUserIssuerId(uuid::Uuid); + +impl DirectUserIngress { + pub fn new_window() -> Self { + Self { + issuer_id: DirectUserIssuerId(uuid::Uuid::new_v4()), + } + } + + /// Returns the opaque identity that the owning UI host must register with + /// its action dispatcher. Possessing an independently-created issuer with a + /// different identity never grants direct-user authority. + pub fn issuer_id(&self) -> DirectUserIssuerId { + self.issuer_id + } + + /// Mint only while handling the corresponding physical pointer callback. + pub fn pointer(&self) -> DirectUserProvenance { + DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::Pointer, + } + } + + /// Mint only for an explicit command-palette activation initiated by a + /// direct-user pointer/key event. + pub fn command_palette(&self) -> DirectUserProvenance { + DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::CommandPalette, + } + } + + /// Starts provenance for a physical multi-stroke key sequence. The token + /// remains valid across GPUI's shorter-prefix timeout only while all three + /// host generations remain unchanged. + pub fn begin_key_sequence( + &self, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + ) -> PendingKeyProvenance { + PendingKeyProvenance { + issuer_id: self.issuer_id, + focus_generation, + context_generation, + keymap_generation, + active: true, + } + } + + /// Constructs a DirectUser invocation and rejects provenance minted for a + /// different window-scoped ingress. Keep this ingress object private to + /// the UI host; do not expose it to model, Python, macro, generated UI, or + /// generic internal dispatch code. + #[allow(clippy::too_many_arguments)] + pub fn trusted_invocation( + &self, + invocation_id: InvocationId, + provenance: DirectUserProvenance, + source_task: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Result { + if provenance.issuer_id != self.issuer_id { + return Err(TrustedInvocationError::WrongWindowProvenance); + } + Ok(TrustedInvocation::new_direct_user( + invocation_id, + provenance, + source_task, + controller_access, + policy_epoch, + cancellation, + action_budget, + )) + } +} + +impl Default for DirectUserIngress { + fn default() -> Self { + Self::new_window() + } +} + +#[derive(Debug)] +pub struct DirectUserProvenance { + issuer_id: DirectUserIssuerId, + transport: InvocationTransport, +} + +/// Physical-key provenance retained while GPUI resolves a pending sequence. +/// It is intentionally not cloneable or serializable. +#[derive(Debug)] +pub struct PendingKeyProvenance { + issuer_id: DirectUserIssuerId, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + active: bool, +} + +impl PendingKeyProvenance { + pub fn invalidate(&mut self) { + self.active = false; + } + + pub fn is_active(&self) -> bool { + self.active + } + + /// Consumes a successfully resolved physical keybinding. Focus/context + /// changes, profile reloads, recorder takeover, cancellation, or an + /// unresolved timeout must invalidate or fail this token. + pub fn consume_resolved( + self, + focus_generation: u64, + context_generation: u64, + keymap_generation: u64, + ) -> Result { + if !self.active { + return Err(PendingKeyProvenanceError::Invalidated); + } + if self.focus_generation != focus_generation { + return Err(PendingKeyProvenanceError::FocusChanged); + } + if self.context_generation != context_generation { + return Err(PendingKeyProvenanceError::ContextChanged); + } + if self.keymap_generation != keymap_generation { + return Err(PendingKeyProvenanceError::KeymapChanged); + } + Ok(DirectUserProvenance { + issuer_id: self.issuer_id, + transport: InvocationTransport::Keybinding, + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum PendingKeyProvenanceError { + #[error("pending key provenance was invalidated")] + Invalidated, + #[error("focus changed during pending key resolution")] + FocusChanged, + #[error("key context changed during pending key resolution")] + ContextChanged, + #[error("keymap generation changed during pending key resolution")] + KeymapChanged, +} + +/// Host-assigned execution provenance. +/// +/// This type deliberately implements neither [`Serialize`] nor [`Deserialize`]. +/// Controller wire requests contain only `ActionCall`; a trusted host creates +/// this value after attaching the current policy, identity, budget, and +/// cancellation state. +/// +/// ```compile_fail +/// use maple_harness::TrustedInvocation; +/// let _: TrustedInvocation = serde_json::from_str("{}").unwrap(); +/// ``` +#[derive(Clone, Debug)] +pub struct TrustedInvocation { + invocation_id: InvocationId, + source_task: Option, + program_id: Option, + program_started_unix_ms: Option, + model_run_id: Option, + execution_id: Option, + kernel_generation: Option, + actor: InvocationActor, + transport: InvocationTransport, + direct_user_issuer_id: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, +} + +impl TrustedInvocation { + #[allow(clippy::too_many_arguments)] + pub fn new( + invocation_id: InvocationId, + source_task: Option, + program_id: Option, + model_run_id: Option, + execution_id: Option, + kernel_generation: Option, + actor: InvocationActor, + transport: InvocationTransport, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Result { + if actor == InvocationActor::DirectUser { + return Err(TrustedInvocationError::DirectUserRequiresProvenance); + } + Ok(Self { + invocation_id, + source_task, + program_id, + program_started_unix_ms: None, + model_run_id, + execution_id, + kernel_generation, + actor, + transport, + direct_user_issuer_id: None, + controller_access, + policy_epoch, + cancellation, + action_budget, + }) + } + + #[allow(clippy::too_many_arguments)] + fn new_direct_user( + invocation_id: InvocationId, + provenance: DirectUserProvenance, + source_task: Option, + controller_access: UiControllerAccess, + policy_epoch: u64, + cancellation: CancellationToken, + action_budget: ActionBudget, + ) -> Self { + Self { + invocation_id, + source_task, + program_id: None, + program_started_unix_ms: None, + model_run_id: None, + execution_id: None, + kernel_generation: None, + actor: InvocationActor::DirectUser, + transport: provenance.transport, + direct_user_issuer_id: Some(provenance.issuer_id), + controller_access, + policy_epoch, + cancellation, + action_budget, + } + } + + pub fn invocation_id(&self) -> InvocationId { + self.invocation_id + } + + pub fn source_task(&self) -> Option<&TaskIdentity> { + self.source_task.as_ref() + } + + pub fn program_id(&self) -> Option { + self.program_id + } + + /// Attach the immutable root-program start minted by the Code Mode + /// kernel. This consumes the invocation so callers cannot mutate an + /// already admitted provenance value in place. + pub fn with_program_started_unix_ms(mut self, started_unix_ms: u64) -> Self { + self.program_started_unix_ms = Some(started_unix_ms); + self + } + + pub fn program_started_unix_ms(&self) -> Option { + self.program_started_unix_ms + } + + pub fn model_run_id(&self) -> Option { + self.model_run_id.clone() + } + + pub fn execution_id(&self) -> Option { + self.execution_id + } + + pub fn kernel_generation(&self) -> Option { + self.kernel_generation + } + + pub fn actor(&self) -> InvocationActor { + self.actor + } + + pub fn transport(&self) -> InvocationTransport { + self.transport + } + + /// Returns the issuer identity retained from opaque physical-input + /// provenance. Non-direct invocations never have an issuer identity. + pub fn direct_user_issuer_id(&self) -> Option { + self.direct_user_issuer_id + } + + pub fn controller_access(&self) -> UiControllerAccess { + self.controller_access + } + + pub fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + pub fn cancellation(&self) -> &CancellationToken { + &self.cancellation + } + + pub fn action_budget(&self) -> &ActionBudget { + &self.action_budget + } + + /// Evaluates this host-attached provenance against an action descriptor. + /// Wire callers cannot choose or replace any of the authority fields used + /// by this check. + pub fn authorize(&self, descriptor: &ActionDescriptor) -> PolicyDecision { + authorize( + descriptor.invocation_policy, + descriptor.effect, + self.actor, + self.transport, + self.controller_access, + ) + } + + /// Creates a follow-up invocation while preserving all authority and lease + /// state. Only the per-action invocation ID changes. + pub fn derived(&self, invocation_id: InvocationId) -> Self { + let mut derived = self.clone(); + derived.invocation_id = invocation_id; + derived + } + + /// Checks cancellation and policy lease freshness without consuming budget. + pub fn check_active(&self, current_policy_epoch: u64) -> Result<(), InvocationGuardError> { + if self.cancellation.is_cancelled() { + return Err(InvocationGuardError::Cancelled); + } + if self.policy_epoch != current_policy_epoch { + return Err(InvocationGuardError::PolicyEpochChanged { + expected: self.policy_epoch, + current: current_policy_epoch, + }); + } + Ok(()) + } + + /// Final admission check for one action in a program or chain. + pub fn admit_call(&self, current_policy_epoch: u64) -> Result { + self.check_active(current_policy_epoch)?; + self.action_budget + .try_consume() + .map_err(InvocationGuardError::Budget) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum TrustedInvocationError { + #[error("DirectUser invocations require opaque physical-input provenance")] + DirectUserRequiresProvenance, + #[error("direct-user provenance was minted for a different window")] + WrongWindowProvenance, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum InvocationGuardError { + #[error("program was cancelled")] + Cancelled, + #[error("controller policy epoch changed from {expected} to {current}")] + PolicyEpochChanged { expected: u64, current: u64 }, + #[error(transparent)] + Budget(#[from] ActionBudgetError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_only_and_full_access_follow_the_effect_matrix() { + for actor in [InvocationActor::Model, InvocationActor::UserCode] { + for transport in [ + InvocationTransport::Python, + InvocationTransport::GeneratedUi, + ] { + for effect in [ActionEffect::Observe, ActionEffect::Navigate] { + assert!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::ReadOnly, + ) + .is_allowed() + ); + } + for effect in [ActionEffect::MutateMaple, ActionEffect::ExternalEffect] { + assert_eq!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::ReadOnly, + ) + .denial_code(), + Some(PolicyDenialCode::ReadOnlyMutation) + ); + assert!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::FullAccess, + ) + .is_allowed() + ); + } + } + } + } + + #[test] + fn off_denies_every_controller_effect() { + for actor in [ + InvocationActor::Model, + InvocationActor::UserCode, + InvocationActor::Internal, + ] { + for transport in InvocationTransport::ALL { + for effect in [ + ActionEffect::Observe, + ActionEffect::Navigate, + ActionEffect::MutateMaple, + ActionEffect::ExternalEffect, + ] { + assert_eq!( + authorize( + InvocationPolicy::ControllerCallable, + effect, + actor, + transport, + UiControllerAccess::Off, + ) + .denial_code(), + Some(PolicyDenialCode::ControllerOff), + "unexpected result for {actor:?}/{transport:?}/{effect:?}" + ); + } + } + } + } + + #[test] + fn human_only_matrix_is_exhaustive() { + for actor in InvocationActor::ALL { + for transport in InvocationTransport::ALL { + let decision = authorize( + InvocationPolicy::HumanOnly, + ActionEffect::Observe, + actor, + transport, + UiControllerAccess::FullAccess, + ); + assert_eq!( + decision.is_allowed(), + is_allowed_direct_user_origin(actor, transport), + "unexpected Human Only result for {actor:?}/{transport:?}" + ); + } + } + } + + #[test] + fn internal_follow_up_inherits_provenance_policy_and_shared_budget() { + let cancellation = CancellationToken::new(); + let invocation = TrustedInvocation::new( + InvocationId::new(), + Some(TaskIdentity::new("account", "task")), + Some(ProgramId::new()), + Some(RunId::new()), + Some(ExecutionId::new()), + Some(3), + InvocationActor::Model, + InvocationTransport::Python, + UiControllerAccess::ReadOnly, + 7, + cancellation, + ActionBudget::new(2).unwrap(), + ) + .unwrap() + .with_program_started_unix_ms(1_000); + let follow_up = invocation.derived(InvocationId::new()); + + assert_eq!(follow_up.actor(), InvocationActor::Model); + assert_eq!(follow_up.transport(), InvocationTransport::Python); + assert_eq!(follow_up.direct_user_issuer_id(), None); + assert_eq!(follow_up.controller_access(), UiControllerAccess::ReadOnly); + assert_eq!(follow_up.policy_epoch(), 7); + assert_eq!(follow_up.program_id(), invocation.program_id()); + assert_eq!(follow_up.program_started_unix_ms(), Some(1_000)); + assert_eq!(follow_up.admit_call(7).unwrap(), 1); + assert_eq!(invocation.admit_call(7).unwrap(), 2); + assert!(matches!( + follow_up.admit_call(7), + Err(InvocationGuardError::Budget(ActionBudgetError::Exhausted { + limit: 2 + })) + )); + } + + #[test] + fn cancellation_and_policy_revocation_prevent_subsequent_calls() { + let cancellation = CancellationToken::new(); + let invocation = TrustedInvocation::new( + InvocationId::new(), + None, + Some(ProgramId::new()), + None, + Some(ExecutionId::new()), + None, + InvocationActor::UserCode, + InvocationTransport::Python, + UiControllerAccess::FullAccess, + 4, + cancellation.clone(), + ActionBudget::new(5).unwrap(), + ) + .unwrap(); + + assert!(invocation.admit_call(5).is_err()); + assert_eq!(invocation.action_budget().consumed(), 0); + assert!(invocation.admit_call(4).is_ok()); + cancellation.cancel(); + assert_eq!( + invocation.admit_call(4), + Err(InvocationGuardError::Cancelled) + ); + assert_eq!(invocation.action_budget().consumed(), 1); + } + + #[test] + fn direct_user_requires_move_only_physical_input_provenance() { + assert!(matches!( + TrustedInvocation::new( + InvocationId::new(), + None, + None, + None, + None, + None, + InvocationActor::DirectUser, + InvocationTransport::Pointer, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ), + Err(TrustedInvocationError::DirectUserRequiresProvenance) + )); + + let ingress = DirectUserIngress::new_window(); + let invocation = ingress + .trusted_invocation( + InvocationId::new(), + ingress.pointer(), + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ) + .unwrap(); + assert_eq!(invocation.actor(), InvocationActor::DirectUser); + assert_eq!(invocation.transport(), InvocationTransport::Pointer); + assert_eq!( + invocation.direct_user_issuer_id(), + Some(ingress.issuer_id()) + ); + } + + #[test] + fn pending_key_provenance_survives_resolved_timeout_but_not_host_state_changes() { + let ingress = DirectUserIngress::new_window(); + let resolved = ingress + .begin_key_sequence(10, 20, 30) + .consume_resolved(10, 20, 30) + .unwrap(); + let invocation = ingress + .trusted_invocation( + InvocationId::new(), + resolved, + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ) + .unwrap(); + assert_eq!(invocation.transport(), InvocationTransport::Keybinding); + + assert!(matches!( + ingress + .begin_key_sequence(10, 20, 30) + .consume_resolved(11, 20, 30), + Err(PendingKeyProvenanceError::FocusChanged) + )); + let mut cancelled = ingress.begin_key_sequence(10, 20, 30); + cancelled.invalidate(); + assert!(matches!( + cancelled.consume_resolved(10, 20, 30), + Err(PendingKeyProvenanceError::Invalidated) + )); + } + + #[test] + fn direct_user_provenance_is_window_scoped() { + let first = DirectUserIngress::new_window(); + let second = DirectUserIngress::new_window(); + assert_ne!(first.issuer_id(), second.issuer_id()); + assert!(matches!( + second.trusted_invocation( + InvocationId::new(), + first.pointer(), + None, + UiControllerAccess::Off, + 1, + CancellationToken::new(), + ActionBudget::new(1).unwrap(), + ), + Err(TrustedInvocationError::WrongWindowProvenance) + )); + } + + #[test] + fn authority_tuple_is_normalized_and_fail_closed() { + assert!( + ProgrammabilityAuthority::new(PythonCodeMode::Off, UiControllerAccess::ReadOnly) + .is_err() + ); + let full = ProgrammabilityAuthority::new( + PythonCodeMode::DeveloperPreview, + UiControllerAccess::FullAccess, + ) + .unwrap(); + assert_eq!( + full.with_code_mode(PythonCodeMode::Off), + ProgrammabilityAuthority::OFF + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "code_mode": "off", + "controller": "full_access" + })) + .is_err() + ); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/registry.rs b/apps/maple-agent/crates/maple-harness/src/registry.rs new file mode 100644 index 00000000..69205ed4 --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/registry.rs @@ -0,0 +1,478 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionDescriptor, ActionEffect, ActionId, AuditSpecError, ContextExpression, + ContextExpressionError, InvocationPolicy, KeySequence, KeySequenceError, SCHEMA_VERSION, +}; + +#[derive(Clone, Debug, Default)] +pub struct RegistryBuilder { + descriptors: Vec, + adapters: Vec, +} + +impl RegistryBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, descriptor: ActionDescriptor) -> &mut Self { + self.descriptors.push(descriptor); + self + } + + /// Records that the app provides the one typed GPUI adapter corresponding + /// to a bindable semantic action. The core stores only the stable ID and + /// remains GPUI-free. + pub fn register_adapter(&mut self, action_id: ActionId) -> &mut Self { + self.adapters.push(action_id); + self + } + + pub fn build(self) -> Result> { + let mut errors = Vec::new(); + let mut descriptors = BTreeMap::new(); + for descriptor in self.descriptors { + if descriptors.contains_key(&descriptor.id) { + errors.push(RegistryValidationError::DuplicateActionId( + descriptor.id.clone(), + )); + continue; + } + validate_descriptor(&descriptor, &mut errors); + descriptors.insert(descriptor.id.clone(), descriptor); + } + + let mut adapters = BTreeSet::new(); + for action_id in self.adapters { + if !adapters.insert(action_id.clone()) { + errors.push(RegistryValidationError::DuplicateAdapter(action_id)); + } + } + for adapter in &adapters { + match descriptors.get(adapter) { + None => errors.push(RegistryValidationError::AdapterForUnknownAction( + adapter.clone(), + )), + Some(descriptor) if !descriptor.bindable => errors.push( + RegistryValidationError::AdapterForNonBindableAction(adapter.clone()), + ), + Some(_) => {} + } + } + for descriptor in descriptors.values() { + if descriptor.bindable && !adapters.contains(&descriptor.id) { + errors.push(RegistryValidationError::MissingAdapter( + descriptor.id.clone(), + )); + } + } + + if errors.is_empty() { + Ok(ActionRegistry { + descriptors, + adapters, + }) + } else { + Err(errors) + } + } +} + +fn validate_descriptor(descriptor: &ActionDescriptor, errors: &mut Vec) { + if descriptor.schema_version != SCHEMA_VERSION { + errors.push(RegistryValidationError::UnsupportedSchemaVersion { + action_id: descriptor.id.clone(), + schema_version: descriptor.schema_version, + supported: SCHEMA_VERSION, + }); + } + validate_required_copy(descriptor, errors); + validate_schema( + &descriptor.id, + SchemaKind::Arguments, + &descriptor.argument_schema, + errors, + ); + validate_schema( + &descriptor.id, + SchemaKind::Result, + &descriptor.result_schema, + errors, + ); + if let Err(source) = descriptor.audit.validate() { + errors.push(RegistryValidationError::UnsafeAuditSpec { + action_id: descriptor.id.clone(), + source, + }); + } + if !descriptor.bindable && !descriptor.default_bindings.is_empty() { + errors.push(RegistryValidationError::DefaultBindingOnNonBindableAction( + descriptor.id.clone(), + )); + } + if requires_human_only(&descriptor.id) + && descriptor.invocation_policy != InvocationPolicy::HumanOnly + { + errors.push(RegistryValidationError::AuthorityActionNotHumanOnly( + descriptor.id.clone(), + )); + } + if descriptor.id.as_str() == "permission.respond" + && (descriptor.invocation_policy != InvocationPolicy::ControllerCallable + || descriptor.effect != ActionEffect::MutateMaple) + { + errors.push(RegistryValidationError::InvalidPermissionRespondContract( + descriptor.id.clone(), + )); + } + if descriptor.id.as_str() == "app.quit" + && (!descriptor.terminal_host_action + || descriptor.invocation_policy != InvocationPolicy::ControllerCallable) + { + errors.push(RegistryValidationError::InvalidAppQuitContract( + descriptor.id.clone(), + )); + } + for (binding_index, binding) in descriptor.default_bindings.iter().enumerate() { + if let Err(source) = ContextExpression::parse(binding.context.clone()) { + errors.push(RegistryValidationError::InvalidDefaultContext { + action_id: descriptor.id.clone(), + binding_index, + source, + }); + } + if let Err(source) = KeySequence::parse(binding.sequence.clone()) { + errors.push(RegistryValidationError::InvalidDefaultSequence { + action_id: descriptor.id.clone(), + binding_index, + source, + }); + } + if let Err(source) = descriptor.validate_arguments(&binding.arguments) { + errors.push(RegistryValidationError::InvalidDefaultArguments { + action_id: descriptor.id.clone(), + binding_index, + message: source.to_string(), + }); + } + } +} + +fn validate_required_copy( + descriptor: &ActionDescriptor, + errors: &mut Vec, +) { + for (field, value) in [ + (DescriptorCopyField::Label, descriptor.label.as_str()), + ( + DescriptorCopyField::Description, + descriptor.description.as_str(), + ), + (DescriptorCopyField::Category, descriptor.category.as_str()), + ] { + if value.trim().is_empty() || value.trim() != value { + errors.push(RegistryValidationError::MissingOrInvalidCopy { + action_id: descriptor.id.clone(), + field, + }); + } + } +} + +fn validate_schema( + action_id: &ActionId, + kind: SchemaKind, + schema: &Value, + errors: &mut Vec, +) { + if !schema.is_object() { + errors.push(RegistryValidationError::MissingSchema { + action_id: action_id.clone(), + kind, + }); + return; + } + if let Err(error) = jsonschema::validator_for(schema) { + errors.push(RegistryValidationError::InvalidSchema { + action_id: action_id.clone(), + kind, + message: error.to_string(), + }); + } +} + +fn requires_human_only(action_id: &ActionId) -> bool { + let id = action_id.as_str(); + id.starts_with("auth.") + || matches!( + id, + "account.sign_out" + | "account.delete" + | "code_mode.set_enabled" + | "code_mode.set_controller_access" + ) + || (id.starts_with("account.") + && [ + "delete", + "email", + "password", + "recovery", + "mfa", + "credential", + "token", + "revoke", + ] + .iter() + .any(|sensitive| id.contains(sensitive))) +} + +#[derive(Clone, Debug)] +pub struct ActionRegistry { + descriptors: BTreeMap, + adapters: BTreeSet, +} + +impl ActionRegistry { + pub fn descriptor(&self, action_id: &ActionId) -> Option<&ActionDescriptor> { + self.descriptors.get(action_id) + } + + pub fn contains(&self, action_id: &ActionId) -> bool { + self.descriptors.contains_key(action_id) + } + + pub fn has_adapter(&self, action_id: &ActionId) -> bool { + self.adapters.contains(action_id) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.descriptors.iter() + } + + pub fn len(&self) -> usize { + self.descriptors.len() + } + + pub fn is_empty(&self) -> bool { + self.descriptors.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SchemaKind { + Arguments, + Result, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DescriptorCopyField { + Label, + Description, + Category, +} + +#[derive(Clone, Debug, Error, PartialEq)] +pub enum RegistryValidationError { + #[error("duplicate stable action ID {0}")] + DuplicateActionId(ActionId), + #[error( + "action {action_id} uses schema version {schema_version}, but this host supports {supported}" + )] + UnsupportedSchemaVersion { + action_id: ActionId, + schema_version: u16, + supported: u16, + }, + #[error("action {action_id} has missing or invalid {field:?}")] + MissingOrInvalidCopy { + action_id: ActionId, + field: DescriptorCopyField, + }, + #[error("action {action_id} is missing an object-shaped {kind:?} schema")] + MissingSchema { + action_id: ActionId, + kind: SchemaKind, + }, + #[error("action {action_id} has invalid {kind:?} schema: {message}")] + InvalidSchema { + action_id: ActionId, + kind: SchemaKind, + message: String, + }, + #[error("action {action_id} has an unsafe audit specification: {source}")] + UnsafeAuditSpec { + action_id: ActionId, + source: AuditSpecError, + }, + #[error("authority-changing action {0} must be Human Only")] + AuthorityActionNotHumanOnly(ActionId), + #[error("{0} must be a controller-callable Mutate Maple action")] + InvalidPermissionRespondContract(ActionId), + #[error("{0} must be controller-callable and marked as a terminal host action")] + InvalidAppQuitContract(ActionId), + #[error("action {action_id} default binding {binding_index} has invalid context: {source}")] + InvalidDefaultContext { + action_id: ActionId, + binding_index: usize, + source: ContextExpressionError, + }, + #[error("action {action_id} default binding {binding_index} has invalid sequence: {source}")] + InvalidDefaultSequence { + action_id: ActionId, + binding_index: usize, + source: KeySequenceError, + }, + #[error("action {action_id} default binding {binding_index} has invalid arguments: {message}")] + InvalidDefaultArguments { + action_id: ActionId, + binding_index: usize, + message: String, + }, + #[error("bindable action {0} has no registered typed GPUI adapter")] + MissingAdapter(ActionId), + #[error("non-bindable action {0} cannot declare default key bindings")] + DefaultBindingOnNonBindableAction(ActionId), + #[error("duplicate typed GPUI adapter registration for {0}")] + DuplicateAdapter(ActionId), + #[error("typed GPUI adapter references unknown action {0}")] + AdapterForUnknownAction(ActionId), + #[error("typed GPUI adapter references non-bindable action {0}")] + AdapterForNonBindableAction(ActionId), +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{ + ActionEffect, AuditSpec, DefaultBinding, InvocationPolicy, Recoverability, ShortcutProfile, + }; + + fn descriptor(id: &str) -> ActionDescriptor { + ActionDescriptor { + schema_version: SCHEMA_VERSION, + id: ActionId::parse(id).unwrap(), + label: "Test action".into(), + description: "A complete test action descriptor".into(), + category: "Tests".into(), + argument_schema: json!({"type": "object", "additionalProperties": false}), + result_schema: json!({"type": "object"}), + contexts: Vec::new(), + effect: ActionEffect::Observe, + invocation_policy: InvocationPolicy::ControllerCallable, + recoverability: Recoverability::Ephemeral, + audit: AuditSpec::default(), + default_bindings: Vec::new(), + precondition_domain: None, + bindable: true, + terminal_host_action: false, + } + } + + #[test] + fn registry_rejects_duplicate_descriptors() { + let descriptor = descriptor("task.open"); + let mut builder = RegistryBuilder::new(); + builder.register(descriptor.clone()).register(descriptor); + builder.register_adapter(ActionId::parse("task.open").unwrap()); + assert!( + builder + .build() + .unwrap_err() + .iter() + .any(|error| matches!(error, RegistryValidationError::DuplicateActionId(_))) + ); + } + + #[test] + fn registry_requires_documentation_schemas_and_typed_adapter() { + let mut invalid = descriptor("task.open"); + invalid.label = " ".into(); + invalid.result_schema = Value::Null; + let mut builder = RegistryBuilder::new(); + builder.register(invalid); + let errors = builder.build().unwrap_err(); + assert!( + errors + .iter() + .any(|error| matches!(error, RegistryValidationError::MissingOrInvalidCopy { .. })) + ); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::MissingSchema { + kind: SchemaKind::Result, + .. + } + ))); + assert!( + errors + .iter() + .any(|error| matches!(error, RegistryValidationError::MissingAdapter(_))) + ); + } + + #[test] + fn registry_rejects_unparsable_or_schema_invalid_defaults() { + let mut invalid = descriptor("composer.send"); + invalid.argument_schema = json!({ + "type": "object", + "properties": {"steer": {"type": "boolean"}}, + "additionalProperties": false + }); + invalid.default_bindings = vec![DefaultBinding { + profile: ShortcutProfile::Standard, + context: "MapleApp && (".into(), + sequence: "ctrl-enter ".into(), + arguments: json!({"steer": "yes"}), + }]; + let mut builder = RegistryBuilder::new(); + builder.register(invalid); + builder.register_adapter(ActionId::parse("composer.send").unwrap()); + let errors = builder.build().unwrap_err(); + assert!( + errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultContext { .. } + )) + ); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultSequence { .. } + ))); + assert!(errors.iter().any(|error| matches!( + error, + RegistryValidationError::InvalidDefaultArguments { .. } + ))); + } + + #[test] + fn authority_changing_actions_must_be_human_only() { + let mut authority = descriptor("code_mode.set_controller_access"); + authority.invocation_policy = InvocationPolicy::ControllerCallable; + let mut builder = RegistryBuilder::new(); + builder.register(authority); + builder.register_adapter(ActionId::parse("code_mode.set_controller_access").unwrap()); + assert!(builder.build().unwrap_err().iter().any(|error| matches!( + error, + RegistryValidationError::AuthorityActionNotHumanOnly(_) + ))); + } + + #[test] + fn valid_registry_preserves_descriptor_and_adapter_identity() { + let action_id = ActionId::parse("permission.respond").unwrap(); + let mut action = descriptor(action_id.as_str()); + action.effect = ActionEffect::MutateMaple; + let mut builder = RegistryBuilder::new(); + builder.register(action).register_adapter(action_id.clone()); + let registry = builder.build().unwrap(); + assert!(registry.contains(&action_id)); + assert!(registry.has_adapter(&action_id)); + assert_eq!(registry.descriptor(&action_id).unwrap().id, action_id); + } +} diff --git a/apps/maple-agent/crates/maple-harness/src/semantic.rs b/apps/maple-agent/crates/maple-harness/src/semantic.rs new file mode 100644 index 00000000..caac309f --- /dev/null +++ b/apps/maple-agent/crates/maple-harness/src/semantic.rs @@ -0,0 +1,1578 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + fmt, + ops::Range, + str::FromStr, +}; + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, de}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + ActionError, ActionId, ActionStatus, Availability, InvocationId, OperationId, + ProgrammabilityAuthority, UiControllerAccess, +}; + +pub const DEFAULT_SEMANTIC_EVENT_CAPACITY: usize = 2_048; +pub const DEFAULT_SEMANTIC_NODE_PAGE_LIMIT: usize = 200; +pub const DEFAULT_SNAPSHOT_TEXT_LIMIT_BYTES: usize = 128 * 1024; +pub const MAX_EVENT_WAIT_TIMEOUT_MS: u64 = 60_000; + +macro_rules! semantic_name { + ($name:ident, $label:literal) => { + #[derive(Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(transparent)] + #[schemars(transparent)] + pub struct $name( + #[schemars(length(min = 1, max = 64), pattern(r"^[a-z][a-z0-9_.]*$"))] String, + ); + + impl $name { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_semantic_name(&value, $label)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = SemanticNameError; + + fn from_str(value: &str) -> Result { + Self::parse(value) + } + } + + impl TryFrom<&str> for $name { + type Error = SemanticNameError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(de::Error::custom) + } + } + }; +} + +semantic_name!(ScreenId, "screen ID"); +semantic_name!(RegionId, "region ID"); +semantic_name!(SemanticKind, "semantic kind"); + +impl ScreenId { + pub fn login() -> Self { + Self::parse("login").expect("built-in screen ID is valid") + } + + pub fn chat() -> Self { + Self::parse("chat").expect("built-in screen ID is valid") + } + + pub fn settings() -> Self { + Self::parse("settings").expect("built-in screen ID is valid") + } + + pub fn code_mode() -> Self { + Self::parse("code_mode").expect("built-in screen ID is valid") + } +} + +impl RegionId { + pub fn app() -> Self { + Self::parse("app").expect("built-in region ID is valid") + } + + pub fn sidebar() -> Self { + Self::parse("sidebar").expect("built-in region ID is valid") + } + + pub fn transcript() -> Self { + Self::parse("transcript").expect("built-in region ID is valid") + } + + pub fn composer() -> Self { + Self::parse("composer").expect("built-in region ID is valid") + } + + pub fn settings() -> Self { + Self::parse("settings").expect("built-in region ID is valid") + } + + pub fn overlay() -> Self { + Self::parse("overlay").expect("built-in region ID is valid") + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SemanticNameError { + #[error("{kind} is empty")] + Empty { kind: &'static str }, + #[error("{kind} exceeds 64 bytes")] + TooLong { kind: &'static str }, + #[error("{kind} must start with an ASCII lowercase letter")] + InvalidStart { kind: &'static str }, + #[error("{kind} contains invalid character {character:?} at byte {byte_index}")] + InvalidCharacter { + kind: &'static str, + byte_index: usize, + character: char, + }, +} + +fn validate_semantic_name(value: &str, kind: &'static str) -> Result<(), SemanticNameError> { + if value.is_empty() { + return Err(SemanticNameError::Empty { kind }); + } + if value.len() > 64 { + return Err(SemanticNameError::TooLong { kind }); + } + if !value.as_bytes()[0].is_ascii_lowercase() { + return Err(SemanticNameError::InvalidStart { kind }); + } + for (byte_index, character) in value.char_indices() { + if !(character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '.') + { + return Err(SemanticNameError::InvalidCharacter { + kind, + byte_index, + character, + }); + } + } + Ok(()) +} + +/// A stable target. It intentionally contains no render indices, GPUI entity +/// IDs, focus handles, or other ephemeral UI identities. +#[derive( + Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SemanticTarget { + App, + Screen { + screen: ScreenId, + }, + Region { + region: RegionId, + }, + Project { + canonical_root: String, + }, + Task { + task_id: String, + }, + TimelineItem { + task_id: String, + item_id: String, + }, + Annotation { + task_id: String, + item_id: String, + annotation_id: String, + }, + Permission { + request_id: String, + }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { + task_id: String, + queue_id: String, + }, + DraftAttachment { + draft_id: u64, + }, + Setting { + key: String, + }, + MenuItem { + menu_id: String, + item_id: String, + }, +} + +impl SemanticTarget { + pub fn task_id(&self) -> Option<&str> { + match self { + Self::Task { task_id } + | Self::TimelineItem { task_id, .. } + | Self::Annotation { task_id, .. } + | Self::QueueItem { task_id, .. } => Some(task_id), + _ => None, + } + } + + /// Human-readable display path for logs/UI only. Executors must continue to + /// use the structural enum rather than parsing this string. + pub fn display_path(&self) -> String { + match self { + Self::App => "app".into(), + Self::Screen { screen } => format!("screen:{screen}"), + Self::Region { region } => format!("region:{region}"), + Self::Project { canonical_root } => format!("project:{canonical_root}"), + Self::Task { task_id } => format!("task:{task_id}"), + Self::TimelineItem { task_id, item_id } => { + format!("task:{task_id}/timeline:{item_id}") + } + Self::Annotation { + task_id, + item_id, + annotation_id, + } => format!("task:{task_id}/timeline:{item_id}/annotation:{annotation_id}"), + Self::Permission { request_id } => format!("permission:{request_id}"), + Self::Question { + request_id, + question_id, + } => format!("request:{request_id}/question:{question_id}"), + Self::QueueItem { task_id, queue_id } => { + format!("task:{task_id}/queue:{queue_id}") + } + Self::DraftAttachment { draft_id } => format!("draft_attachment:{draft_id}"), + Self::Setting { key } => format!("setting:{key}"), + Self::MenuItem { menu_id, item_id } => format!("menu:{menu_id}/item:{item_id}"), + } + } +} + +#[derive( + Clone, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize, Deserialize, +)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RevisionDomain { + Global, + Task { + task_id: String, + }, + Project { + canonical_root: String, + }, + Timeline { + task_id: String, + }, + TimelineItem { + task_id: String, + item_id: String, + }, + Draft { + task_id: String, + }, + Setting { + key: String, + }, + Permission { + request_id: String, + }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { + task_id: String, + queue_id: String, + }, + Target { + target: SemanticTarget, + }, +} + +impl RevisionDomain { + /// Collapses the structural aliases that can otherwise give one semantic + /// object two independent revision counters. + pub fn canonicalized(&self) -> Self { + match self { + Self::Target { target } => match target { + SemanticTarget::App => Self::Global, + SemanticTarget::Project { canonical_root } => Self::Project { + canonical_root: canonical_root.clone(), + }, + SemanticTarget::Task { task_id } => Self::Task { + task_id: task_id.clone(), + }, + SemanticTarget::TimelineItem { task_id, item_id } => Self::TimelineItem { + task_id: task_id.clone(), + item_id: item_id.clone(), + }, + SemanticTarget::Permission { request_id } => Self::Permission { + request_id: request_id.clone(), + }, + SemanticTarget::Question { + request_id, + question_id, + } => Self::Question { + request_id: request_id.clone(), + question_id: question_id.clone(), + }, + SemanticTarget::QueueItem { task_id, queue_id } => Self::QueueItem { + task_id: task_id.clone(), + queue_id: queue_id.clone(), + }, + SemanticTarget::Setting { key } => Self::Setting { key: key.clone() }, + _ => self.clone(), + }, + _ => self.clone(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RevisionChange { + pub state_revision: u64, + pub target_revision: u64, +} + +/// Tracks the coherent global state revision separately from action-specific +/// target revisions. +#[derive(Clone, Debug, Default)] +pub struct RevisionTracker { + state_revision: u64, + domains: BTreeMap, +} + +impl RevisionTracker { + pub fn new() -> Self { + Self::default() + } + + pub fn state_revision(&self) -> u64 { + self.state_revision + } + + pub fn revision(&self, domain: &RevisionDomain) -> u64 { + let domain = domain.canonicalized(); + if domain == RevisionDomain::Global { + self.state_revision + } else { + self.domains.get(&domain).copied().unwrap_or(0) + } + } + + pub fn bump(&mut self, domain: RevisionDomain) -> RevisionChange { + let domain = domain.canonicalized(); + self.state_revision = self.state_revision.saturating_add(1); + let target_revision = if domain == RevisionDomain::Global { + self.state_revision + } else { + let revision = self.domains.entry(domain).or_default(); + *revision = revision.saturating_add(1); + *revision + }; + RevisionChange { + state_revision: self.state_revision, + target_revision, + } + } + + pub fn check(&self, domain: &RevisionDomain, expected: u64) -> Result<(), StaleRevision> { + let domain = domain.canonicalized(); + let actual = self.revision(&domain); + if actual == expected { + Ok(()) + } else { + Err(StaleRevision { + domain, + expected, + actual, + }) + } + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("revision for {domain:?} changed from {expected} to {actual}")] +pub struct StaleRevision { + pub domain: RevisionDomain, + pub expected: u64, + pub actual: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InsertionSummary { + pub target: SemanticTarget, + pub byte_offset: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_byte_range: Option>, + pub draft_revision: u64, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ViewportSummary { + pub region: RegionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_visible: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_visible: Option, + pub visible_count: usize, +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActionAvailabilitySummary { + pub action_id: ActionId, + pub availability: Availability, +} + +#[derive(Clone, Debug, Default, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SemanticNodeState { + pub selected: bool, + pub expanded: bool, + pub disabled: bool, + pub busy: bool, + /// Small, already-redacted semantic attributes. Never place arbitrary + /// application state or credentials here. + pub attributes: BTreeMap, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticNode { + pub target: SemanticTarget, + pub kind: SemanticKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub state: SemanticNodeState, + #[serde(default)] + pub available_actions: Vec, + #[serde(default)] + pub children: Vec, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq)] +pub struct SemanticSnapshot { + pub schema_version: u16, + pub state_revision: u64, + pub event_cursor: u64, + pub screen: ScreenId, + pub active_region: RegionId, + pub selection: Option, + pub insertion: Option, + pub viewport: ViewportSummary, + pub stream_follow: bool, + pub roots: Vec, +} + +impl SemanticSnapshot { + /// Rejects secret-shaped attribute keys before a snapshot crosses the + /// controller boundary. Projection labels still remain the host's + /// responsibility and should contain only visible product text. + pub fn validate_redaction(&self) -> Result<(), SnapshotRedactionError> { + for root in &self.roots { + validate_node_attributes(root)?; + } + Ok(()) + } +} + +impl Serialize for SemanticSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::Error as _; + + self.validate_redaction().map_err(S::Error::custom)?; + #[derive(Serialize)] + #[serde(deny_unknown_fields)] + struct Wire<'a> { + schema_version: u16, + state_revision: u64, + event_cursor: u64, + screen: &'a ScreenId, + active_region: &'a RegionId, + #[serde(skip_serializing_if = "Option::is_none")] + selection: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + insertion: &'a Option, + viewport: &'a ViewportSummary, + stream_follow: bool, + roots: &'a [SemanticNode], + } + + Wire { + schema_version: self.schema_version, + state_revision: self.state_revision, + event_cursor: self.event_cursor, + screen: &self.screen, + active_region: &self.active_region, + selection: &self.selection, + insertion: &self.insertion, + viewport: &self.viewport, + stream_follow: self.stream_follow, + roots: &self.roots, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SemanticSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + schema_version: u16, + state_revision: u64, + event_cursor: u64, + screen: ScreenId, + active_region: RegionId, + #[serde(default)] + selection: Option, + #[serde(default)] + insertion: Option, + viewport: ViewportSummary, + stream_follow: bool, + #[serde(default)] + roots: Vec, + } + + let wire = Wire::deserialize(deserializer)?; + let snapshot = Self { + schema_version: wire.schema_version, + state_revision: wire.state_revision, + event_cursor: wire.event_cursor, + screen: wire.screen, + active_region: wire.active_region, + selection: wire.selection, + insertion: wire.insertion, + viewport: wire.viewport, + stream_follow: wire.stream_follow, + roots: wire.roots, + }; + snapshot.validate_redaction().map_err(de::Error::custom)?; + Ok(snapshot) + } +} + +fn validate_node_attributes(node: &SemanticNode) -> Result<(), SnapshotRedactionError> { + for (key, value) in &node.state.attributes { + validate_attribute_value(key, value)?; + } + for child in &node.children { + validate_node_attributes(child)?; + } + Ok(()) +} + +fn validate_attribute_value(key: &str, value: &Value) -> Result<(), SnapshotRedactionError> { + if semantic_field_is_sensitive(key) { + return Err(SnapshotRedactionError::SensitiveAttribute(key.to_owned())); + } + match value { + Value::Object(object) => { + for (nested_key, nested_value) in object { + validate_attribute_value(nested_key, nested_value)?; + } + } + Value::Array(values) => { + for value in values { + validate_attribute_value(key, value)?; + } + } + _ => {} + } + Ok(()) +} + +fn semantic_field_is_sensitive(field: &str) -> bool { + let normalized = field.to_ascii_lowercase(); + [ + "password", + "passwd", + "secret", + "token", + "credential", + "authorization", + "cookie", + "oauth", + "callback", + "private_key", + "api_key", + "header", + "environment", + "permission_payload", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum SnapshotRedactionError { + #[error("semantic snapshot attribute {0:?} appears sensitive")] + SensitiveAttribute(String), +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticSelection { + pub region: RegionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anchor: Option, + pub explicit: bool, + pub observed_revision: u64, +} + +impl SemanticSelection { + pub fn new(region: RegionId, target: Option, observed_revision: u64) -> Self { + Self { + region, + target, + anchor: None, + explicit: false, + observed_revision, + } + } + + pub fn explicit(region: RegionId, target: SemanticTarget, observed_revision: u64) -> Self { + Self { + region, + target: Some(target), + anchor: None, + explicit: true, + observed_revision, + } + } + + pub fn select(&mut self, target: SemanticTarget, observed_revision: u64, explicit: bool) { + self.target = Some(target); + self.explicit = explicit; + self.observed_revision = observed_revision; + } + + pub fn clear(&mut self, observed_revision: u64) { + self.target = None; + self.anchor = None; + self.explicit = false; + self.observed_revision = observed_revision; + } +} + +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RegionReturnReason { + Overlay, + Dialog, + EnterComposer, + RegionNavigation, + Other, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RegionReturnFrame { + pub token: RegionReturnToken, + pub screen: ScreenId, + pub region: RegionId, + pub target: Option, + pub reason: RegionReturnReason, + pub relevant_revision: u64, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RegionReturnToken { + stack_id: uuid::Uuid, + sequence: u64, +} + +impl RegionReturnToken { + pub fn get(self) -> u64 { + self.sequence + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RegionReturnPush { + pub token: RegionReturnToken, + pub truncated_oldest: bool, +} + +#[derive(Debug)] +pub struct RegionReturnStack { + capacity: usize, + stack_id: uuid::Uuid, + next_token: u64, + frames: VecDeque, +} + +impl RegionReturnStack { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(RegionReturnStackError::ZeroCapacity); + } + Ok(Self { + capacity, + stack_id: uuid::Uuid::new_v4(), + next_token: 1, + frames: VecDeque::with_capacity(capacity), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn push( + &mut self, + screen: ScreenId, + region: RegionId, + target: Option, + reason: RegionReturnReason, + relevant_revision: u64, + ) -> RegionReturnPush { + let token = RegionReturnToken { + stack_id: self.stack_id, + sequence: self.next_token, + }; + self.next_token = self.next_token.saturating_add(1); + let truncated_oldest = self.frames.len() == self.capacity; + if truncated_oldest { + self.frames.pop_front(); + } + self.frames.push_back(RegionReturnFrame { + token, + screen, + region, + target, + reason, + relevant_revision, + }); + RegionReturnPush { + token, + truncated_oldest, + } + } + + /// Pops only the exact top frame. It never searches through and removes an + /// unrelated lower overlay/composer return point. + pub fn pop_exact( + &mut self, + token: RegionReturnToken, + ) -> Result { + let top = self.frames.back().ok_or(RegionReturnStackError::Empty)?; + if top.token != token { + return Err(RegionReturnStackError::TokenMismatch { + expected_top: top.token, + requested: token, + }); + } + self.frames.pop_back().ok_or(RegionReturnStackError::Empty) + } + + pub fn top(&self) -> Option<&RegionReturnFrame> { + self.frames.back() + } + + pub fn len(&self) -> usize { + self.frames.len() + } + + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum RegionReturnStackError { + #[error("region return stack capacity must be greater than zero")] + ZeroCapacity, + #[error("region return stack is empty")] + Empty, + #[error("region return token {requested:?} does not match top frame {expected_top:?}")] + TokenMismatch { + expected_top: RegionReturnToken, + requested: RegionReturnToken, + }, +} + +/// Eligibility flags used to derive a navigable projection from a raw backing +/// collection. This keeps hidden/internal/zero-presence rows out of semantic +/// navigation while allowing off-screen but revealable objects. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SemanticPresence { + pub internal_only: bool, + pub filtered_out: bool, + pub revealable: bool, + pub nonzero_presence: bool, +} + +impl SemanticPresence { + pub const fn navigable(self) -> bool { + !self.internal_only && !self.filtered_out && self.revealable && self.nonzero_presence + } +} + +pub fn navigable_targets<'a>( + candidates: impl IntoIterator, +) -> Vec { + candidates + .into_iter() + .filter(|(_, presence)| presence.navigable()) + .map(|(target, _)| target.clone()) + .collect() +} + +/// Reconciles a removed selection through stable IDs. It prefers the next item +/// at the removed item's former position, then the previous surviving item. +pub fn reconcile_removed_selection( + previous_order: &[SemanticTarget], + current_order: &[SemanticTarget], + selected: &SemanticTarget, +) -> Option { + if current_order.contains(selected) { + return Some(selected.clone()); + } + let old_index = previous_order + .iter() + .position(|target| target == selected)?; + + for distance in 1..=previous_order.len() { + if let Some(candidate) = previous_order.get(old_index + distance) + && current_order.contains(candidate) + { + return Some(candidate.clone()); + } + if let Some(candidate_index) = old_index.checked_sub(distance) + && let Some(candidate) = previous_order.get(candidate_index) + && current_order.contains(candidate) + { + return Some(candidate.clone()); + } + } + None +} + +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SemanticEventKind { + ScreenChanged, + RegionChanged, + SemanticSelectionChanged, + TargetUpdated, + TargetRemoved, + TaskOpened, + ActionStarted, + ActionCompleted, + ControllerPolicyChanged, + CodeModeStateChanged, +} + +/// Terminal subset accepted by an `action_completed` event. `Accepted` is +/// deliberately excluded because asynchronous acceptance is not completion. +#[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalActionStatus { + AcceptedTerminal, + Completed, + Failed, + Cancelled, +} + +impl TryFrom for TerminalActionStatus { + type Error = NonTerminalActionStatus; + + fn try_from(status: ActionStatus) -> Result { + match status { + ActionStatus::AcceptedTerminal => Ok(Self::AcceptedTerminal), + ActionStatus::Completed => Ok(Self::Completed), + ActionStatus::Failed => Ok(Self::Failed), + ActionStatus::Cancelled => Ok(Self::Cancelled), + ActionStatus::Accepted => Err(NonTerminalActionStatus), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[error("accepted is not a terminal action status")] +pub struct NonTerminalActionStatus; + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SemanticEventPayload { + ScreenChanged { + screen: ScreenId, + }, + RegionChanged { + region: RegionId, + }, + SemanticSelectionChanged { + selection: SemanticSelection, + }, + TargetUpdated { + target: SemanticTarget, + target_revision: u64, + }, + TargetRemoved { + target: SemanticTarget, + }, + TaskOpened { + task_id: String, + }, + ActionStarted { + invocation_id: InvocationId, + action_id: ActionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + ActionCompleted { + invocation_id: InvocationId, + action_id: ActionId, + status: TerminalActionStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + operation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + ControllerPolicyChanged { + controller_access: UiControllerAccess, + policy_epoch: u64, + }, + CodeModeStateChanged { + authority: ProgrammabilityAuthority, + }, +} + +impl SemanticEventPayload { + pub const fn kind(&self) -> SemanticEventKind { + match self { + Self::ScreenChanged { .. } => SemanticEventKind::ScreenChanged, + Self::RegionChanged { .. } => SemanticEventKind::RegionChanged, + Self::SemanticSelectionChanged { .. } => SemanticEventKind::SemanticSelectionChanged, + Self::TargetUpdated { .. } => SemanticEventKind::TargetUpdated, + Self::TargetRemoved { .. } => SemanticEventKind::TargetRemoved, + Self::TaskOpened { .. } => SemanticEventKind::TaskOpened, + Self::ActionStarted { .. } => SemanticEventKind::ActionStarted, + Self::ActionCompleted { .. } => SemanticEventKind::ActionCompleted, + Self::ControllerPolicyChanged { .. } => SemanticEventKind::ControllerPolicyChanged, + Self::CodeModeStateChanged { .. } => SemanticEventKind::CodeModeStateChanged, + } + } + + pub fn target(&self) -> Option<&SemanticTarget> { + match self { + Self::SemanticSelectionChanged { selection } => selection.target.as_ref(), + Self::TargetUpdated { target, .. } | Self::TargetRemoved { target } => Some(target), + Self::ActionStarted { target, .. } => target.as_ref(), + _ => None, + } + } + + pub fn task_id(&self) -> Option<&str> { + match self { + Self::TaskOpened { task_id } => Some(task_id), + _ => self.target().and_then(SemanticTarget::task_id), + } + } + + pub fn action_id(&self) -> Option<&ActionId> { + match self { + Self::ActionStarted { action_id, .. } | Self::ActionCompleted { action_id, .. } => { + Some(action_id) + } + _ => None, + } + } + + pub fn invocation_id(&self) -> Option { + match self { + Self::ActionStarted { invocation_id, .. } + | Self::ActionCompleted { invocation_id, .. } => Some(*invocation_id), + _ => None, + } + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticEvent { + pub schema_version: u16, + pub cursor: u64, + pub state_revision: u64, + pub payload: SemanticEventPayload, +} + +#[derive(Clone, Debug, Default, Eq, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SemanticEventPredicate { + pub kind: Option, + pub target: Option, + pub task_id: Option, + pub action_id: Option, + pub invocation_id: Option, +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EventWaitRequest { + pub predicate: SemanticEventPredicate, + pub after_cursor: u64, + pub timeout_ms: u64, +} + +impl EventWaitRequest { + pub fn new( + predicate: SemanticEventPredicate, + after_cursor: u64, + timeout_ms: u64, + ) -> Result { + if timeout_ms == 0 || timeout_ms > MAX_EVENT_WAIT_TIMEOUT_MS { + return Err(EventWaitRequestError::InvalidTimeout { + maximum_ms: MAX_EVENT_WAIT_TIMEOUT_MS, + requested_ms: timeout_ms, + }); + } + Ok(Self { + predicate, + after_cursor, + timeout_ms, + }) + } +} + +impl<'de> Deserialize<'de> for EventWaitRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + predicate: SemanticEventPredicate, + after_cursor: u64, + timeout_ms: u64, + } + + let wire = Wire::deserialize(deserializer)?; + Self::new(wire.predicate, wire.after_cursor, wire.timeout_ms).map_err(de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum EventWaitRequestError { + #[error( + "events.wait timeout {requested_ms} ms must be between 1 and {maximum_ms} ms inclusive" + )] + InvalidTimeout { maximum_ms: u64, requested_ms: u64 }, +} + +impl SemanticEventPredicate { + pub fn matches(&self, event: &SemanticEvent) -> bool { + self.kind.is_none_or(|kind| event.payload.kind() == kind) + && self + .target + .as_ref() + .is_none_or(|target| event.payload.target() == Some(target)) + && self + .task_id + .as_deref() + .is_none_or(|task_id| event.payload.task_id() == Some(task_id)) + && self + .action_id + .as_ref() + .is_none_or(|action_id| event.payload.action_id() == Some(action_id)) + && self + .invocation_id + .is_none_or(|invocation_id| event.payload.invocation_id() == Some(invocation_id)) + } +} + +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticEventPage { + pub events: Vec, + /// Last cursor inspected, including nonmatching events. + pub scanned_through: u64, + pub has_more: bool, +} + +#[derive(Clone, Debug)] +pub struct SemanticEventRing { + capacity: usize, + next_cursor: u64, + events: VecDeque, +} + +impl SemanticEventRing { + pub fn new(capacity: usize) -> Result { + if capacity == 0 { + return Err(SemanticEventRingError::ZeroCapacity); + } + Ok(Self { + capacity, + next_cursor: 1, + events: VecDeque::with_capacity(capacity), + }) + } + + pub fn push(&mut self, state_revision: u64, payload: SemanticEventPayload) -> SemanticEvent { + let event = SemanticEvent { + schema_version: crate::SCHEMA_VERSION, + cursor: self.next_cursor, + state_revision, + payload, + }; + self.next_cursor = self.next_cursor.saturating_add(1); + if self.events.len() == self.capacity { + self.events.pop_front(); + } + self.events.push_back(event.clone()); + event + } + + pub fn latest_cursor(&self) -> u64 { + self.events.back().map_or(0, |event| event.cursor) + } + + pub fn oldest_cursor(&self) -> Option { + self.events.front().map(|event| event.cursor) + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + pub fn events_after( + &self, + after: u64, + predicate: &SemanticEventPredicate, + limit: usize, + ) -> Result { + if limit == 0 { + return Err(SemanticEventRingError::ZeroLimit); + } + let latest = self.latest_cursor(); + if after > latest { + return Err(SemanticEventRingError::FutureCursor { + latest, + requested_after: after, + }); + } + if let Some(oldest) = self.oldest_cursor() + && after.saturating_add(1) < oldest + { + return Err(SemanticEventRingError::ResyncRequired { + oldest_available: oldest, + requested_after: after, + }); + } + + let mut events = Vec::new(); + let mut scanned_through = after; + let mut has_more = false; + for event in self.events.iter().filter(|event| event.cursor > after) { + if events.len() == limit { + has_more = true; + break; + } + scanned_through = event.cursor; + if predicate.matches(event) { + events.push(event.clone()); + } + } + if !has_more { + scanned_through = latest.max(scanned_through); + } + Ok(SemanticEventPage { + events, + scanned_through, + has_more, + }) + } +} + +impl Default for SemanticEventRing { + fn default() -> Self { + Self::new(DEFAULT_SEMANTIC_EVENT_CAPACITY) + .expect("default semantic event ring capacity is nonzero") + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, JsonSchema, PartialEq, Serialize, Deserialize)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum SemanticEventRingError { + #[error("semantic event ring capacity must be greater than zero")] + ZeroCapacity, + #[error("semantic event page limit must be greater than zero")] + ZeroLimit, + #[error( + "event cursor {requested_after} is older than retained history; oldest available is {oldest_available}" + )] + ResyncRequired { + oldest_available: u64, + requested_after: u64, + }, + #[error("event cursor {requested_after} is ahead of latest cursor {latest}")] + FutureCursor { latest: u64, requested_after: u64 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn task(id: &str) -> SemanticTarget { + SemanticTarget::Task { task_id: id.into() } + } + + #[test] + fn semantic_targets_are_structural_hashable_wire_values() { + let target = SemanticTarget::Annotation { + task_id: "task-1".into(), + item_id: "item-2".into(), + annotation_id: "annotation-3".into(), + }; + let value = serde_json::to_value(&target).unwrap(); + assert_eq!(value["kind"], "annotation"); + assert_eq!(value["task_id"], "task-1"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + target + ); + + let mut targets = std::collections::HashSet::new(); + targets.insert(target.clone()); + assert!(targets.contains(&target)); + } + + #[test] + fn relevant_revision_changes_stale_only_the_relevant_precondition() { + let task_domain = RevisionDomain::Task { + task_id: "task-1".into(), + }; + let streaming_domain = RevisionDomain::TimelineItem { + task_id: "task-2".into(), + item_id: "assistant".into(), + }; + let mut revisions = RevisionTracker::new(); + let first = revisions.bump(task_domain.clone()); + assert_eq!(first.target_revision, 1); + + revisions.bump(streaming_domain); + assert!(revisions.check(&task_domain, 1).is_ok()); + + revisions.bump(task_domain.clone()); + assert_eq!(revisions.check(&task_domain, 1).unwrap_err().actual, 2); + } + + #[test] + fn revision_domains_canonicalize_structural_target_aliases() { + let task_domain = RevisionDomain::Task { + task_id: "task-1".into(), + }; + let target_alias = RevisionDomain::Target { + target: SemanticTarget::Task { + task_id: "task-1".into(), + }, + }; + let mut revisions = RevisionTracker::new(); + revisions.bump(task_domain); + assert_eq!(revisions.revision(&target_alias), 1); + assert!(revisions.check(&target_alias, 0).is_err()); + } + + #[test] + fn navigation_projection_excludes_hidden_internal_and_zero_presence_rows() { + let visible = task("visible"); + let hidden = task("hidden"); + let internal = task("internal"); + let zero_height = task("zero"); + let projected = navigable_targets([ + ( + &visible, + SemanticPresence { + internal_only: false, + filtered_out: false, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &hidden, + SemanticPresence { + internal_only: false, + filtered_out: true, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &internal, + SemanticPresence { + internal_only: true, + filtered_out: false, + revealable: true, + nonzero_presence: true, + }, + ), + ( + &zero_height, + SemanticPresence { + internal_only: false, + filtered_out: false, + revealable: true, + nonzero_presence: false, + }, + ), + ]); + assert_eq!(projected, vec![visible]); + } + + #[test] + fn selection_identity_survives_insertion_and_has_deterministic_removal_neighbor() { + let a = task("a"); + let b = task("b"); + let c = task("c"); + let inserted = task("inserted"); + assert_eq!( + reconcile_removed_selection( + &[a.clone(), b.clone(), c.clone()], + &[inserted, a.clone(), b.clone(), c.clone()], + &b, + ), + Some(b.clone()) + ); + assert_eq!( + reconcile_removed_selection( + &[a.clone(), b.clone(), c.clone()], + &[a.clone(), c.clone()], + &b, + ), + Some(c) + ); + assert_eq!( + reconcile_removed_selection(std::slice::from_ref(&a), &[], &a), + None + ); + } + + #[test] + fn bounded_event_ring_is_sequence_safe_and_requires_resync_after_overflow() { + let mut ring = SemanticEventRing::new(2).unwrap(); + ring.push( + 1, + SemanticEventPayload::TaskOpened { + task_id: "one".into(), + }, + ); + let cursor = ring.latest_cursor(); + ring.push( + 2, + SemanticEventPayload::TaskOpened { + task_id: "two".into(), + }, + ); + ring.push( + 3, + SemanticEventPayload::TaskOpened { + task_id: "three".into(), + }, + ); + + assert!(matches!( + ring.events_after(0, &SemanticEventPredicate::default(), 10), + Err(SemanticEventRingError::ResyncRequired { .. }) + )); + let page = ring + .events_after( + cursor, + &SemanticEventPredicate { + kind: Some(SemanticEventKind::TaskOpened), + task_id: Some("three".into()), + ..SemanticEventPredicate::default() + }, + 10, + ) + .unwrap(); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].cursor, 3); + assert_eq!(page.scanned_through, 3); + } + + #[test] + fn event_cursor_from_describe_closes_query_then_wait_race() { + let mut ring = SemanticEventRing::new(8).unwrap(); + let snapshot_cursor = ring.latest_cursor(); + ring.push( + 1, + SemanticEventPayload::TaskOpened { + task_id: "task-1".into(), + }, + ); + let page = ring + .events_after(snapshot_cursor, &SemanticEventPredicate::default(), 8) + .unwrap(); + assert_eq!(page.events.len(), 1); + assert_eq!(page.events[0].payload.task_id(), Some("task-1")); + } + + #[test] + fn region_return_stack_is_bounded_and_lifo_for_surviving_frames() { + let mut stack = RegionReturnStack::new(2).unwrap(); + let mut pushes = Vec::new(); + for region in ["sidebar", "transcript", "settings"] { + pushes.push(stack.push( + ScreenId::chat(), + RegionId::parse(region).unwrap(), + None, + RegionReturnReason::RegionNavigation, + 1, + )); + } + assert_eq!(stack.len(), 2); + assert!(pushes[2].truncated_oldest); + assert!(matches!( + stack.pop_exact(pushes[1].token), + Err(RegionReturnStackError::TokenMismatch { .. }) + )); + assert_eq!( + stack.pop_exact(pushes[2].token).unwrap().region.as_str(), + "settings" + ); + assert_eq!( + stack.pop_exact(pushes[1].token).unwrap().region.as_str(), + "transcript" + ); + } + + #[test] + fn region_return_tokens_cannot_cross_stacks() { + let mut first = RegionReturnStack::new(2).unwrap(); + let mut second = RegionReturnStack::new(2).unwrap(); + let first_token = first + .push( + ScreenId::chat(), + RegionId::sidebar(), + None, + RegionReturnReason::Overlay, + 1, + ) + .token; + second.push( + ScreenId::chat(), + RegionId::sidebar(), + None, + RegionReturnReason::Overlay, + 1, + ); + assert!(matches!( + second.pop_exact(first_token), + Err(RegionReturnStackError::TokenMismatch { .. }) + )); + } + + #[test] + fn action_completed_and_wait_request_are_validated_on_wire() { + assert!(TerminalActionStatus::try_from(ActionStatus::Accepted).is_err()); + assert!( + serde_json::from_value::(serde_json::json!("accepted")).is_err() + ); + let operation_id = OperationId::new(); + let completion = SemanticEvent { + schema_version: crate::SCHEMA_VERSION, + cursor: 8, + state_revision: 13, + payload: SemanticEventPayload::ActionCompleted { + invocation_id: InvocationId::new(), + action_id: ActionId::parse("settings.set_theme").unwrap(), + status: TerminalActionStatus::Completed, + operation_id: Some(operation_id), + result: Some(serde_json::json!({ + "local_applied": true, + "durable": true + })), + error: None, + }, + }; + let wire = serde_json::to_value(&completion).unwrap(); + assert_eq!(wire["payload"]["kind"], "action_completed"); + assert_eq!(wire["payload"]["operation_id"], operation_id.to_string()); + assert_eq!(wire["payload"]["result"]["durable"], true); + assert!(wire["payload"].get("error").is_none()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + completion + ); + assert!(EventWaitRequest::new(SemanticEventPredicate::default(), 0, 60_000).is_ok()); + assert!(EventWaitRequest::new(SemanticEventPredicate::default(), 0, 60_001).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "predicate": {}, + "after_cursor": 0, + "timeout_ms": 0 + })) + .is_err() + ); + } + + #[test] + fn snapshot_wire_boundary_rejects_secret_shaped_attributes() { + let snapshot = SemanticSnapshot { + schema_version: crate::SCHEMA_VERSION, + state_revision: 1, + event_cursor: 0, + screen: ScreenId::chat(), + active_region: RegionId::transcript(), + selection: None, + insertion: None, + viewport: ViewportSummary { + region: RegionId::transcript(), + first_visible: None, + last_visible: None, + visible_count: 1, + }, + stream_follow: true, + roots: vec![SemanticNode { + target: SemanticTarget::App, + kind: SemanticKind::parse("application").unwrap(), + label: None, + state: SemanticNodeState { + attributes: BTreeMap::from([( + "oauth_token".into(), + Value::String("sentinel-secret".into()), + )]), + ..SemanticNodeState::default() + }, + available_actions: Vec::new(), + children: Vec::new(), + }], + }; + let error = serde_json::to_string(&snapshot).unwrap_err().to_string(); + assert!(!error.contains("sentinel-secret")); + assert!(error.contains("oauth_token")); + } +} diff --git a/apps/maple-agent/docs/programmable-harness-distillation.md b/apps/maple-agent/docs/programmable-harness-distillation.md new file mode 100644 index 00000000..d323cf93 --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness-distillation.md @@ -0,0 +1,354 @@ +# Programmable harness: distillation of the retired prototype + +> Written 2026-09-09 from a read of benthecarman/maple-gpui PR #2 +> (`programmable-harness`, head 653b959) before that repository was deleted. +> It reconstructs the desktop-side modules that were **not** ported into this +> monorepo (`app/src/harness/*`, `app/src/desktop/controller_runtime.rs`, +> `app/src/backend/code_mode.rs`, the Python `maple_gpui` SDK) so that a +> production design can cite the concepts without the code. Companion files: +> `programmable-harness.md` (the original normative design) and +> `programmable-harness-preview-pr.md` (the original PR description). What +> *is* ported lives in `crates/maple-harness/`; see its module docs for which +> of the tiers below each file re-expresses. + +## 0. Crate topology and the one-sentence thesis + +``` +crates/maple-harness GPUI-free wire nouns: ActionId/Descriptor/Call/Response, + policy, audit ring, semantic targets/events, registry. +crates/maple-code-mode GPUI-free Python worker service: kernels, processes, + protocol framing, authority, controller transport trait. +app/src/harness/* Desktop ingress: catalog, ActionHost, controller bridge, + keymap compiler, palette, which-key, provenance, vim. +app/src/desktop/* The GPUI root that implements the host traits. +app/src/backend/code_mode.rs App-side ownership of the worker service. +``` + +**Thesis:** every state change in the app is a *descriptor-validated semantic +action* that passes through exactly one `ActionHost::invoke_observed` call, +carrying an unforgeable `TrustedInvocation` that names its actor, transport, +controller-access lease and policy epoch. Everything else (keymap, palette, +which-key, Python SDK) is a *projection or transport*, never an executor. + +## 1. Core nouns + +```rust +pub enum InvocationPolicy { ControllerCallable, HumanOnly } +pub enum InvocationActor { DirectUser, Model, UserCode, Internal } +pub enum InvocationTransport { Pointer, Keybinding, CommandPalette, Python, Macro, GeneratedUi, Internal } +pub enum UiControllerAccess { Off, ReadOnly, FullAccess } // default Off +pub enum PythonCodeMode { Off, DeveloperPreview } // default Off +pub enum ActionEffect { Observe, Navigate, MutateMaple, ExternalEffect } +pub enum Recoverability { Ephemeral, Reversible, Irreversible } +pub enum ActionStatus { Accepted, AcceptedTerminal, Completed, Failed, Cancelled } +pub enum ActionErrorCode { UnknownAction, InvalidArguments, NotApplicable, Unavailable, + PolicyDenied, StaleTarget, Cancelled, Failed } +``` + +The authorization function is a pure five-argument decision: + +```rust +pub fn authorize(policy, effect, actor, transport, controller_access) -> PolicyDecision { + let direct = actor == DirectUser + && matches!(transport, Pointer | Keybinding | CommandPalette); + if actor == DirectUser && !direct { Denied(InvalidDirectUserOrigin) } + if policy == HumanOnly { return if direct { Allowed } else { Denied(HumanOnly) } } + if direct { return Allowed } + match controller_access { + Off => Denied(ControllerOff), + ReadOnly => match effect { Observe|Navigate => Allowed, + MutateMaple|ExternalEffect => Denied(ReadOnlyMutation) }, + FullAccess => Allowed, + } +} +``` + +`TrustedInvocation` bundles: invocation id, optional `TaskIdentity`, +`ProgramId`, `RunId`, `ExecutionId`, kernel generation, actor, transport, +`controller_access`, `policy_epoch`, `CancellationToken`, `ActionBudget`, and +(for DirectUser) an opaque window-issued provenance token. `check_active` +rejects cancelled or stale-epoch leases; `admit_call` additionally consumes +budget. **One SDK request got `ActionBudget::new(1)`**: a Python call could +invoke at most one semantic action. + +Audit ring: capacity 512, lifecycle `record → accept → mark_cancel_requested → +finish`. `CompletedAfterCancelRequest` exists for irreversible actions that +finish after a cancel request: cancellation is a request, never fabricated +completion. + +## 2. `harness/host.rs`: the single action host + +```rust +pub(crate) trait ActionExecutor { + fn executor_identity(&self) -> &'static str; // test proof of a single impl + fn availability(&self, &ActionDescriptor, &ActionCall) -> Availability; + fn execute(&mut self, &ActionDescriptor, &ActionCall, &TrustedInvocation, + &mut ActionExecutionLease) -> ActionResponse; +} +pub(crate) struct ActionHost { + registry, audit, authority: ProgrammabilityAuthority, policy_epoch: u64, + direct_user_issuer_id: Option, + admission: ActionAdmissionState, // Active | Suspended | Quitting + operations: HashMap, +} +``` + +Bounds: `MAX_INFLIGHT_ACTIONS = 256` (under the 512 audit ring so terminal +history is always recordable), `RESERVED_LIFECYCLE_OPERATION_SLOTS = 16` +(`run.stop`, `code_mode.stop/reset/restart/…`, `account.sign_out` keep the +full 256 while ordinary actions get 240: a saturated program budget can never +block the human's ability to stop it), `MAX_RECENT_AUDIT_SNAPSHOT = 64`. + +Move-only completion capability: an async executor must +`lease.claim_completion()` and return `Accepted` carrying the leased +`operation_id`; `finish_async_action(token, response)` compares nine fields +including a secret before transitioning the audit exactly once. + +The ordered gate (`invoke_inner`), to be re-implemented verbatim: + +1. registry lookup → `UnknownAction`; +2. admission state: `Suspended` → `dispatcher_busy`, `Quitting` → `app_quitting`; +3. `validate_arguments` → `InvalidArguments`; +4. `validate_precondition` → `StaleTarget`; +5. DirectUser issuer must equal the host's registered issuer; +6. `check_active(policy_epoch)` → `Cancelled` or `PolicyDenied`; +7. live re-check: stored `controller_access != current` → denied; +8. `authorize` → `PolicyDenied`; +9. `availability` → `Disabled{code,message}`; +10. operation-table admission limit; +11. `admit_call` consumes budget; +12. audit `Started` with `descriptor.audit.redact(arguments)`; +13. execute. + +Post-execute normalisation: mismatched ids, schema-invalid results, `Accepted` +without a claimed lease, a terminal-host descriptor returning a non-terminal +status, and duplicate identities all become `Failed`. A claimed lease with a +non-`Accepted` response cancels the invocation so spawned work is revoked. + +Authority is a lease: `advance_authority` bumps the epoch and **revokes every +retained non-DirectUser operation even when the new tuple is broader**. +Shutdown: `begin_quitting()` closes admission, `cancel_all_operations()`, +then `terminalize_shutdown_operations()` is the only way to force-end audit +records. + +## 3. `harness/controller.rs`: the bounded Code Mode bridge + +Constants: queue depth 64, drain batch 16 per UI tick, enqueue timeout 250 ms, +response timeout 30 s, event-wait margin 1 s, max event wait 60 s, max 64 +waiters, response limit 512 KiB, snapshot text limit 128 KiB, semantic page +200, list page 100, cursor capacity 512 with 5 min TTL. + +```rust +pub(crate) trait ControllerUiHost { + fn controller_policy(&self) -> ControllerPolicySnapshot; + fn validate_controller_source(&self, &ControllerProvenance) -> Result<(), ControllerError>; + fn semantic_snapshot(&mut self, scope: &Value) -> Result; + fn semantic_events_after(&self, after: u64, &SemanticEventPredicate, limit) -> Result; + fn action_catalog(&self) -> Vec; + fn resolve_action_call(&self, &ActionCall) -> Result; + fn action_availability(&mut self, &ActionCall) -> Availability; + fn invoke_action(&mut self, ActionCall, TrustedInvocation, &SdkDeliveryBarrier) -> ActionResponse; + fn accepted_terminal_response_sent(&mut self, &ControllerCall); + fn collection(&mut self, ControllerCollectionKind, &ControllerProvenance) -> Result; + fn cancel_task_programs(&mut self, &KernelKey) -> Result; + fn precondition_for_target(&self, &SemanticTarget) -> Option { None } +} +``` + +Lifecycle `OPEN → QUITTING → CLOSED`. `SdkRequestDisposition` is a three-state +CAS (`PENDING | UI_CLAIMED | ABANDONED`): exactly one of caller-abandon and +UI-claim wins; after the UI claims, the response wins every timeout/drop race. +`SdkDeliveryBarrier` tracks the response frame reaching the worker pipe; +`app.quit` waits on it (5 s cap) before destructive shutdown. + +Drain: waiters get half the batch first, then queued requests, then waiters +again; a retained wait alone does not keep the UI loop spinning. Every +request *and every waiter poll* re-runs `authorize_call` against the live +policy (`code_mode_enabled`, `access`, `policy_epoch`, source validation). + +SDK method table: `ui.describe`, `ui.query`, `ui.reveal`, +`ui.current_selection`, `actions.list`, `actions.describe`, +`actions.availability`, `actions.invoke`, `events.wait`, `tasks.list`, +`projects.list`, `transcript.list`, `mcps.list`. Discovery distinguishes +`available`, `needs_arguments` (`arguments_required` reason), and `disabled`. +Cursors bind `(kind, fingerprint, offset, revision, policy_epoch, expiry)`; +a changed revision or epoch invalidates paging. Outgoing list items are +scanned for sensitive key names and every response is size-bounded. + +## 4. `harness/catalog.rs`: the descriptor catalog + +About 180 actions declared through `CatalogSpec` macros with `FieldKind` +(`Bool, Count, Digit, McpServer, Number, Object, String, StringAllowEmpty, +StringArray, UuidString, Choice`). Schemas always set +`additionalProperties: false` with an explicit `required` list. Durable +side-effect actions return `oneOf {operation_id} | {local_applied, durable, +error?}` so the UI can tell in-memory from on-disk. `mcp.add/update` are +secret-bearing. Every `bindable` descriptor requires a registered typed +adapter, so a missing GPUI adapter is a startup/test failure. + +Human-only class: authentication, sign-out, clipboard-image attach, all raw +text-input editing and Vim grammar primitives, `timeline.copy_selected`, +`shortcuts.reset_profile`, and both Code Mode authority setters. A program can +never widen its own authority, log in, or synthesise keystrokes. + +Representative sample: + +| id | effect / policy / recoverability | bindable, terminal | precondition | arguments | +|---|---|---|---|---| +| `app.quit` | ExternalEffect / ControllerCallable / Irreversible | yes, **yes** | none | none | +| `settings.open_section` | Navigate / ControllerCallable / Ephemeral | yes | none | `section` | +| `shortcuts.reset_profile` | MutateMaple / **HumanOnly** / Irreversible | no | Setting | none | +| `account.sign_out` | MutateMaple / **HumanOnly** / Irreversible | no | none | none | +| `auth.password_submit` | ExternalEffect / **HumanOnly** / Irreversible | no | none | `email, password` | +| `task.open` | Navigate / ControllerCallable / Ephemeral | yes | Task | `task_id` | +| `task.set_archived` | MutateMaple / ControllerCallable / Reversible | no | Task | `task_id, archived` | +| `project.set_trusted` | MutateMaple / ControllerCallable / Reversible | no | Project | `canonical_root, trusted` | +| `composer.set_text` | MutateMaple / ControllerCallable / Reversible | no | Draft | `task_id, text` | +| `composer.send` | ExternalEffect / ControllerCallable / Irreversible | yes | Draft | `task_id?` | +| `text_input.paste` | MutateMaple / **HumanOnly** / Reversible | yes | none | none | +| `composer.vim.motion` | Navigate / **HumanOnly** / Ephemeral | yes | none | `motion; count?` | +| `run.stop` | MutateMaple / ControllerCallable / Irreversible | yes | Task | `task_id` | +| `permission.respond` | MutateMaple / ControllerCallable / Irreversible | no | Permission | `request_id, decision` | +| `ui.activate_selected` | MutateMaple / ControllerCallable / Ephemeral | yes | none | none (contextual alias) | +| `code_mode.execute` | ExternalEffect / ControllerCallable / Irreversible | no | Task | `task_id, code` | +| `code_mode.set_controller_access` | MutateMaple / **HumanOnly** / Reversible | no | Setting | `access` | + +## 5. `harness/provenance.rs`: direct-user provenance + +GPUI actions carry no origin, so the window mints an opaque, non-serialisable +token only from observed physical input and consumes it exactly once. +`WindowProvenance` tracks focus/context/keymap generations plus separate key +and pointer dispatch phases. Pointer: the root opens a phase in capture; one +child may consume once while the event bubbles; mouse-up opens a fresh phase. +Key: `begin_key_sequence()` snapshots the three generations and +`consume_resolved_key()` succeeds only if all still match. A pending chord +closes the immediate dispatch window while retaining provenance, which stops a +programmatic `dispatch_action` from stealing the token. + +## 6. `harness/keymap.rs`: the pure keymap compiler + +Limits: 1 MiB file, 8 strokes per sequence, 4096-byte contexts. JSON format: +sections `{"context": "", "bindings": {"": }}` with +directives `"action.id"`, `["action.id", {...}]`, or `null` (an explicit +unbind). A parallel token scanner attaches line/column to every entry. + +`ContextAnalysis` decomposes top-level `&&` conjunctions into facts +(`Present, Absent, Equals, NotEquals`); any `||` makes overlap `Possible`. +Precedence: user layer over template, later over earlier, specificity +informational. Pipeline: validate everything (any diagnostic aborts the whole +compile), assign ids, attach defaults, resolve exact groups (a winning `null` +disables the whole group), classify conflicts (`Possible > Prefix > Shadow > +Exact`), build the prefix trie from install rules. Which-key continuations +pick the terminal maximising `(context depth, precedence)`, matching GPUI's +own resolution order. Install is all-or-nothing with a last-known-good +fallback; files are written atomically (same-directory 0600 temp, fsync, +rename, fsync parent) under an FNV-1a + byte-length revision CAS. + +Vim template context design: `MapleApp && profile == vim && app_vim_mode == +normal && !TextInput && !ApplicationModal`. Negating the leaf component is the +actual shadow, so ordinary text/IME insertion stays with the focused editor. + +## 7. `keymap_runtime.rs`, `shortcut_store.rs` + +The runtime adapter validates by constructing the typed GPUI action, so a +successful compile cannot fail during installation. The shortcut store runs +off the UI thread, requires an expected file revision (never `Any`), parses +and rejects a malformed current file first, recompiles the whole prospective +keymap before writing, and turns "replace conflicting bindings" into explicit +`null` overrides. + +## 8. `palette.rs`, `which_key.rs`, `application_vim.rs` + +Palette ranking ladder: exact id/alias 1000, exact label 950, id/alias prefix +900, label prefix 850, exact shortcut 825, substring 500, else filtered. A +leading `:` is stripped. Missing required arguments become an inline JSON +editor seeded from the schema, not a disabled row. Navigation inside the +palette is itself semantic (`ui.select_next`, `ui.activate_selected`). + +Which-key: 300 ms delay, 5 s manual timeout, rows grouped by category with +`"{category} commands"` labels for pure prefixes, a generation-fenced +`Hidden/Waiting/Visible` machine, and portable keystroke tokens rebuilt from +modifier fields so the projected prefix matches the compiled trie. + +Application Vim count prefix: digits arrive as the typed +`application.vim.count_digit{digit}` action, never as raw keys; an explicit +allowlist of 18 countable actions; leading zero is a no-op; saturates at +999 999; a pending prefix beats an explicit `count` argument. + +## 9. `harness/semantic.rs`, `desktop/controller_runtime.rs`, `backend/code_mode.rs` + +Semantic projection keeps `entries` (navigable) separate from `known` (all +candidates) so a filter is never reported as a deletion. Selection +reconciliation is by stable target, then filtered retention, then a +successor-preferring neighbour search, then region fallback. `RegionGraph` +gives `ctrl-w hjkl` over a semantic graph rather than pixels. + +The GPUI runtime: final-action reauthorisation maps contextual aliases to one +concrete call on both discovery and execution paths; the real host is swapped +for `ActionHost::suspended()` during dispatch so nested dispatch fails with +`dispatcher_busy`; a root/worker tuple or epoch mismatch reports the +controller as `Off` (split-brain fails closed); source validation ties +provenance to the signed-in account and a live task. + +`AppCodeMode` serialises every authority/account transition under one mutex, +requires transitions to advance exactly one epoch or be an exact retry, and +revokes a program with two independent attempts (worker lease and host-bound +model run). History is bounded (64 per task, 64 tasks, 8 MiB). + +## 10. `maple_gpui` Python SDK + +Always importable; Rust is the authority boundary. `SemanticObject(dict)` and +`SemanticPage(tuple)` (a dict would shadow `page.items`). The flagship pattern +is `actions.invoke_and_wait`: capture `event_cursor` **before** invoking, then +`events.wait({kind: action_completed, invocation_id}, after=cursor)`, so a +completion published before the wait registers is still found in the ring. +No polling, no sleep. + +## 11. Invariant checklist + +1. One execution path; `executor_identity()` proves it in tests. +2. Descriptor first: arguments, results and preconditions validated in and out. +3. Human-only actions need a real pointer/key/palette origin. +4. Provenance is a move-only, window-scoped, generation-fenced capability. +5. Authority is a lease; any epoch change cancels retained non-human work. +6. Stored `controller_access` must equal the live tuple at execution time. +7. Split-brain fails closed. +8. One atomic pre-dispatch winner per SDK request. +9. Final-action reauthorisation on the concrete action. +10. Exactly-once terminal completion via a secret-bearing token. +11. Cancellation is a request; `CompletedAfterCancelRequest` records the truth. +12. Everything is bounded (256 ops, 512 audit, 64 queue, 16/tick, 64 waiters, 512 cursors, 512 KiB responses). +13. Revisions everywhere (cursors, keymap files, semantic targets). +14. All-or-nothing keymap install with last-known-good. +15. Redaction by construction. + +## 12. Minimal faithful re-implementation, in priority order + +**Tier 1, the gate:** `ActionId`/`ActionCall`/`ActionResponse`; +`ActionDescriptor` + registry with adapter enforcement; the policy enums and +pure `authorize`; `ProgrammabilityAuthority` + epoch snapshot; `ActionBudget` ++ `TrustedInvocation`; the audit ring; the `ActionHost` gate with async +completion tokens and epoch-based revocation. *(Ported: action, registry, +policy, audit; re-expressed: `host.rs`.)* + +**Tier 2, catalog and ingress:** the declarative catalog with the Human Only +set intact; `DirectUserIngress` and a headless provenance equivalent. +*(Re-expressed: `catalog.rs`; ported: `DirectUserIngress`/`PendingKeyProvenance`.)* + +**Tier 3, the programmable bridge:** `SdkRequest`/`ControllerProvenance`, +disposition and delivery fences, the bounded bridge with waiter fairness, the +13-method dispatcher with cursors and redaction, event waits over the semantic +ring. *(Re-expressed in compact form: `controller.rs`; ported: semantic events.)* + +**Tier 4, keymap and surfaces:** sequence/context analysis, `compile_keymap` +with precedence/conflicts/trie, all-or-nothing install with last-known-good, +atomic file CAS, the palette model, which-key projection, Vim count state. +*(Ported: `keymap.rs` core; re-expressed: `discovery.rs`.)* + +**Tier 5, semantics:** targets, presence, projection, reveal plans, selection +reconciliation, region graph, return stack, revision ledger. *(Ported: +`semantic.rs`.)* + +**Tier 6, optional Python worker:** kernel identities, worker service traits, +the SDK facade shapes and the cursor-before-invoke protocol. *(Not ported; +the bundled CPython runtime landed separately as Maple #897.)* diff --git a/apps/maple-agent/docs/programmable-harness-preview-pr.md b/apps/maple-agent/docs/programmable-harness-preview-pr.md new file mode 100644 index 00000000..ecf0841d --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness-preview-pr.md @@ -0,0 +1,350 @@ +> **Archived 2026-09-09:** original description of benthecarman/maple-gpui PR #2 ("Developer preview: make Maple a programmable semantic application"), kept for reference alongside `programmable-harness.md`. Links and file paths refer to the retired `maple-gpui` repository. + + +> **Draft / Developer Preview — preserved integration prototype** +> +> This is an architecture-complete proof of concept for discussion and hands-on +> evaluation. It is intentionally broader than a normal feature PR because the +> value is in proving one end-to-end application contract. It is not a claim +> that native Python is production-contained, that every Vim edge case is +> complete, or that this should merge unchanged. The branch is intentionally +> maintained as one squashed commit targeting `master`; focused production +> changes will be extracted and reviewed separately. + +## What I am asking for + +I would especially value feedback on the architectural direction and the seams +between the crates—not a line-by-line endorsement of roughly 90 files at once. + +The central question is: + +> Should Maple have one typed semantic application contract, with pointer UI, +> shortcuts, Vim, Python, and model-driven workflows acting as clients of that +> contract? + +This PR is now the stable full-system reference and dogfood target, not a +request for line-by-line approval of the entire diff. Focused follow-up PRs will +start from `master` and must stand on their own even if the rest of this preview +never ships. If the direction does not feel right, this remains early enough to +change course without committing the product to the experiment. + +## Context: why build this now? + +`maple-gpui` has already demonstrated that Maple's desktop and agent runtime can +exist outside the Tauri shell. That creates an unusually good point to decide +what the native application's control boundary should be before more UI and +automation features accumulate around screen-local callbacks. + +Several useful features were converging at the same time: + +- editable shortcuts and a command palette; +- whole-application Vim navigation; +- a real modal Vim engine for the composer; +- persistent Python for model computation; +- model-driven control of Maple itself; +- future macros, generated UI, and recursive-language-model workflows. + +Building each directly against GPUI callbacks would produce parallel command +systems. Buttons, keybindings, Vim, and model tools would drift on naming, +availability, authorization, completion, cancellation, and audit behavior. +Coordinate clicking or synthetic keystrokes would add another brittle layer, +especially over virtualized transcript/sidebar content. + +This branch pays the migration cost once: existing semantic controls move onto +one typed action spine, and every new interface uses it. + +## The architectural thesis + +```mermaid +flowchart LR + subgraph Human[Human interfaces] + Pointer[Pointer controls] + Keys[Standard shortcuts] + Palette[Command palette] + AppVim[Application Vim] + ComposerVim[Composer Vim] + end + + subgraph Programmatic[Programmatic interfaces] + Model[Model] + PyTool[python_code] + Worker[Per-task Python worker] + SDK[maple_gpui SDK] + end + + Pointer --> Adapters[Typed GPUI adapters] + Keys --> Adapters + Palette --> Adapters + AppVim --> Adapters + ComposerVim --> Adapters + Model --> PyTool --> Worker --> SDK --> Bridge[Bounded controller bridge] + + Adapters --> Host[ActionHost] + Bridge --> Host + Host --> Gates[Availability + policy + provenance + preconditions + cancellation + audit] + Gates --> Executor[One semantic executor] + Executor --> State[Chat / Settings / Login / backend effects] + State --> Projection[Stable semantic snapshots + events] + Projection --> SDK +``` + +The registry is not metadata beside legacy behavior. A pointer click and a +model request for the same operation converge on the same descriptor, policy +check, executor, result, and event. That is the most important invariant in the +branch. + +### Dependency boundaries + +| Layer | Owns | Deliberately does not own | +| --- | --- | --- | +| `maple-harness` | Wire-safe actions, descriptors, stable targets, policy, provenance types, audit, revisions/events, keymap data | GPUI, effects, account clients, Python | +| `app/src/harness` | Catalog/adapters, `ActionHost`, the one executor, keymap runtime, palette, which-key, application Vim, controller bridge | Python execution or credentials | +| Screen/business modules | Live availability and the operation that owns each mutation/effect | A second authorization or action system | +| `maple-code-mode` | Interpreter discovery, framed protocol, persistent account/task kernels, limits, cancellation, process-group lifecycle, controller transport trait | GPUI, account clients, credentials, Maple action implementations | +| `maple-agent` | Model-facing `python_code`, model-run identity/cancellation, conditional built-in controller skill | GPUI or application action execution | +| Python `maple_gpui` SDK | Discovery, invoke/wait, queries, pagination, events, thin task/workspace helpers | Authority tokens, credentials, GPUI/Rust objects, arbitrary method access | + +The dependency direction is intentional: the portable crates describe and +supervise; the app is the only layer that can finally authorize and execute a +Maple UI operation. + +## Important invariants + +### One action, one implementation + +The checked-in catalog currently contains 166 stable actions. The strict source +audit inventories 219 production activation callbacks: 188 route through the +semantic harness and 31 are explicitly classified local mechanics such as +hover, hit testing, scroll, or selection painting. There are no unexplained +legacy-direct gaps. + +### Availability is not authority + +Screens answer whether an operation currently makes business/UI sense. The root +host separately authorizes who may perform it. A visible enabled control never +becomes proof that a controller may call the same action. + +### Provenance is host-assigned + +Programmatic callers cannot claim `DirectUser`, choose their account/task, +select a policy epoch, or mint a capability. Direct-human provenance is a +bounded, single-use root input capability. Generic actions such as “activate +selected” resolve their concrete backing action and reauthorize it; aliases are +not policy shortcuts. + +### Stable identity survives GPUI refactors and virtualization + +The external contract uses stable action IDs and semantic targets—not vector +indices, focus handles, render element IDs, Rust type names, or callbacks. +Targets carry revision/precondition data so stale state fails closed. Semantic +selection, GPUI focus, text insertion, viewport position, and streaming-follow +state remain separate concepts. + +### Accepted is not completed + +Async operations have correlated invocation/operation identities and semantic +completion events. The SDK's `invoke_and_wait` captures the event cursor before +dispatch so a fast completion cannot be missed. Cancellation revokes future +work; it does not pretend already-committed effects were rolled back. + +### Authority is explicit and fail-closed + +| Caller | Observe | Navigate | Mutate | External effect | Human Only | +| --- | ---: | ---: | ---: | ---: | ---: | +| Direct human | Yes | Yes | Yes | Yes | Yes | +| Controller Off | No | No | No | No | No | +| Controller Read Only | Yes | Yes | No | No | No | +| Controller Full Access | Yes | Yes | Yes | Yes | No | + +Code Mode and Controller access are separate settings and both start Off on +launch, account change, and sign-out. Permanent account/identity operations, +authentication, sign-out, and authority changes remain Human Only. Full Access +intentionally permits ordinary mutations—including conspicuously audited +`permission.respond`—without adding a hidden second confirmation system. That +policy line is an explicit review question, not an accidental side effect. + +## What the preview includes + +### Shortcut system and application Vim + +- Complete Standard and Vim replacement profiles, not stacked partial maps. +- Portable `secondary-*` bindings (Cmd on macOS, Ctrl elsewhere). +- `keymap.json` overrides, null/disabled bindings, validation, last-known-good + reload, conflict detection, recording, per-command/profile reset, and search. +- Registry-generated command palette and passive which-key. +- Semantic application navigation across transcript, sidebar, tasks/projects, + settings, menus, questions, permissions, and composer-region transitions. +- Stable off-screen reveal for virtualized lists and streaming-safe selection. + +Application Vim and composer Vim are intentionally different layers. The +application controller navigates semantic objects/regions. The composer owns a +pure grapheme-safe modal editing engine with Normal/Insert/characterwise Visual +modes, motions, operators, counts, register/paste, undo/redo, text objects, and +structured dot repeat. Dot repeat replays the semantic edit recipe, not the raw +keys. + +### Python Code Mode + +- A separate persistent worker per account/task, outside the GPUI process. +- Deterministic interpreter selection: environment override, saved preference, + future bundle, PATH, then platform fallbacks. +- IPython when importable, with a CPython engine that still supports persistent + state and top-level await. Keeping optional IPython versus simplifying to + CPython-only is a useful review question, not a foundational dependency. +- Framed private protocol, fixed worker bootstrap/SDK, source/frame/output/time + limits, active-kernel cap, process groups, Stop/Reset/Restart, crash recovery, + account/task lifecycle, and exact scratch cleanup checks. +- Model `python_code` calls for a task share one process-local namespace. + +The user-facing placement is deliberately quiet after dogfooding feedback: +disclosure, interpreter state, controller mode, limits, active/retained +programs, lifecycle controls, and audit live only in **Settings → +Programmability**. There is no chat-mounted REPL, compact strip, or global Code +Mode HUD. `code_mode.open` remains a compatibility action that opens Settings. + +### Model-driven `maple_gpui` + +When Controller access is enabled, model Python can discover the current +semantic tree, query stable objects, list compact actions, fetch full schemas, +check availability, invoke and await correlated completion, page bounded +collections, and wait on semantic events. It cannot click coordinates, synthesize +keys, receive Maple credentials, or cache authority through a downgrade. + +## Role of this PR + +The implementation is a vertical architectural proof. It remains useful +because it proves that the registry, migration, Vim layers, Python supervision, +and model-driven controller can operate together. It is not the unit proposed +for production merging. + +The branch history is intentionally squashed to one commit so the complete +preview can be rebased directly as upstream evolves. The original logical +history is preserved on the fork at +`programmable-harness-history-79d8277`; it is provenance, not a second active +development stack. + +Focused production work will proceed one bounded section at a time: + +1. Independently valuable fixes already discovered in upstream-existing code, + only where the defect exists without this preview. +2. A minimal refactor of existing interaction handling that adds no commands, + keybindings, Vim behavior, Code Mode, or controller surface and remains + valuable if none of those features ever ship. +3. Composer Vim as a contained feature. +4. Shortcut customization for the existing command and keybinding set only. +5. Application Vim navigation and its new controls after the foundation and + shortcut surface are independently sound. + +Code Mode, `python_code`, controller policy/projection, and the Python SDK stay +in this preview for now. They are not being split or repositioned while the +earlier sections undergo their own design, review, testing, and fixup cycles. + +PR #2 will remain Draft, continue targeting `master`, and be rebased +occasionally. As focused pieces merge, the preview can shrink without becoming +the Git base for those PRs. + +## How to try it + +The debug build is sufficient for interaction testing: + +```sh +nix develop --no-update-lock-file --command cargo run -p maple-gpui --locked +``` + +After signing in: + +1. Open **Settings → Keyboard Shortcuts**. Search by label, stable action ID, + and key; switch Standard/Vim; inspect or record a conflict; disable/reset an + override. +2. In Vim, exercise `j`/`k`, `gg`/`G`, `Ctrl-W h/j/k/l`, `ga`, `gi`, `:`, and + the Space leader/which-key. The composer shows its own mode and supports + edits such as `ciw…Escape`, movement, then `.` on a different word. +3. Open **Settings → Programmability**, acknowledge the disclosure, and enable + Python Code Mode for this session. Ask the model to assign a Python variable + in one `python_code` call and read/update it in a second call. +4. Set Controller access to **Read Only** and ask: + + > Use `python_code` to import `maple_gpui as maple`, call + > `await maple.ui.describe()`, and report the current screen and active + > region. Do not call any other tool. + +5. Still in Read Only, try a reversible navigation and a settings mutation; + navigation should work and mutation should return a structured policy + denial. Full Access can then be used for a reversible ordinary setting + change, restored immediately afterward. + +The preview requires a usable Python 3.10+ interpreter. The Nix shell includes +Python and IPython; a custom executable can be selected in Settings or with +`MAPLE_CODE_MODE_PYTHON`. + +## Validation + +Current exact head (`653b959`, a tree-identical squash of validated head +`79d8277`): + +- `MAPLE_NIX_XCODE_VERSION=26.5 nix develop --no-update-lock-file --command just ci` + passes on macOS. +- Format, strict semantic-control audit, all four warning-denied Clippy feature + matrices, workspace build/tests, native CPython/IPython worker integration, + doctests, and combined headless tests pass. +- Strict inventory: **219 callbacks = 188 semantic + 31 justified local**. +- The project-menu startup race found during the final exact-head run was fixed + with a deterministic mounted-chat test seam; its focused interaction passes + 100 consecutive runs before the full matrix. + +The vertical implementation was also release-built and exercised live on macOS +before the upstream rebase: Settings/shortcuts, application and composer Vim, +persistent Python, model `python_code`, Read Only/Full Access controller policy, +semantic discovery/invocation/events, lifecycle, and exact app/process identity +were checked. That earlier artifact is not presented as proof of the exact +rebased head; this PR is Draft so the current branch can be tried and the +architecture discussed before any production/promotion claim. + +GitHub's normal Linux/macOS/Windows matrix remains additional evidence for the +squashed head. + +## Honest boundaries and non-goals + +This is a high-quality proof of concept, not a hardened Python sandbox. + +The worker is a separate bounded process with a scrubbed environment, private +scratch/runtime, protocol limits, audit hooks, and process-group cleanup. It +still runs as the user's native OS account. Native modules, direct syscalls, +readable user files/credentials, subprocess escape techniques, or network +access may bypass best-effort Python guardrails. Production containment needs a +platform helper/XPC-style boundary with explicit file/network authority. + +Other deliberate non-goals: + +- full Vim/Neovim/plugin compatibility; +- durable Python namespaces or package/virtualenv management; +- bundled Python in every release artifact; +- generated arbitrary UI, third-party action registration, or multi-window + semantic control; +- transactional rollback of already-completed effects; +- a human chat REPL or global program HUD. + +Known production-hardening work includes a retry-addressable cleanup owner until +process-group death is positively confirmed, concurrent/bounded teardown receipt +capture that survives waiter cancellation, OS-backed physical-input provenance +below GPUI's synthetic dispatch seam, broader platform GUI validation, and +continued UX/accessibility refinement. + +## Questions for review + +1. Is one typed semantic action contract the right long-term boundary for the + native Maple app? +2. Do the crate/dependency seams keep policy and effects in the right owner? +3. Is the Read Only / Full Access / Human Only line understandable and useful, + especially the explicit Full Access behavior for `permission.respond`? +4. Does separating application Vim from composer Vim feel like the right model? +5. Is Settings-only Code Mode visibility the right amount of product surface + for this preview? +6. Should the preview retain optional IPython semantics, or simplify to the + smaller CPython-only engine before going further? +7. Which architectural lessons should survive into the smaller standalone + foundation and feature PRs, even if the rest of this preview is discarded? + +Again: the immediate goal is architectural feedback and a real hands-on trial, +not production sign-off or an expectation that this full diff merges as-is. + diff --git a/apps/maple-agent/docs/programmable-harness.md b/apps/maple-agent/docs/programmable-harness.md new file mode 100644 index 00000000..26039cc1 --- /dev/null +++ b/apps/maple-agent/docs/programmable-harness.md @@ -0,0 +1,3275 @@ +# Maple Programmable Harness + +> **Preserved design, 2026-09-09.** This document is carried over unchanged +> (apart from this preface) from the `maple-gpui` Developer Preview branch, +> benthecarman/maple-gpui PR #2 (`programmable-harness`, head 653b959), before +> that repository is deleted. The full prototype (about 90 files, including the +> GPUI wiring, composer Vim engine, shortcut settings, and the `maple_gpui` +> Python SDK) is **not** ported. What this monorepo keeps is: +> +> - `crates/maple-harness/` — the GPUI-free contract crate (actions, +> registry, policy, semantic snapshot/events, audit, keymap) ported as-is; +> - `crates/maple-harness/src/{host,catalog,controller,discovery}.rs` — a +> compact from-scratch re-expression of the desktop-side concepts (single +> action host with final-action reauthorization, descriptor catalog, +> bounded controller bridge, palette and which-key) written so that the +> ideas compile and are unit-tested without a window; +> - `docs/programmable-harness-distillation.md` — a module-by-module +> reconstruction of the desktop code that was not ported, with the +> invariant checklist and a tiered re-implementation order; +> - `docs/programmable-harness-preview-pr.md` — the original PR description. +> +> Sections below that describe GPUI screens, `app/src/harness/*`, or the +> Python worker refer to the old prototype layout and are historical. Paths +> such as `crates/maple-code-mode/` now mean Maple `apps/maple-agent/crates/`; +> the bundled CPython runtime that this design assumed landed separately as +> the cpython + codemode work (Maple #897), which this branch is stacked on. +> Nothing here is wired into the running Agent; a production version will be +> a fresh design that can cite this one. + +Status: normative implementation specification for an architecture-complete Developer Preview. + +Audience: reviewers, contributors, and future maintainers. + +Target: macOS first. Keep the core portable and keep Linux compiling; Linux desktop behavior can be validated by a Linux reviewer. + +This document is intentionally detailed. It records the product thesis, the decisions already made, the required architecture, the initial user experience, the implementation seams in the current repository, and the validation and review contract. Future changes should not replace these decisions with a smaller unrelated feature, a second command system, key simulation, an embedded arbitrary-code path, or claims of sandboxing that are not true. + +## 1. Executive summary + +Vim mode is not the underlying feature. It is one client of a programmable application. + +Maple should expose every meaningful application operation through one typed, discoverable semantic action system. Buttons, keyboard shortcuts, a command palette, whole-application Vim, the model, and Python programs all use that same system. The model can then navigate or operate Maple by writing short programs against semantic state instead of pretending to press keys or clicking coordinates. + +The architectural center is: + +~~~text +pointer / keyboard / palette / Vim / model / Python / future generated UI + | + v + typed semantic actions + | + v + availability + authority + audit + | + v + one action implementation + | + v + Maple state and backend effects + | + v + semantic events + native GPUI rendering +~~~ + +This unlocks several related experiences without building separate automation stacks: + +- A Standard shortcut profile for ordinary users. +- A Vim profile with application-level modal navigation. +- Real Vim editing in the chat composer. +- A searchable command palette and shortcut editor. +- Model-controlled UI navigation and action chaining. +- A persistent per-task Python/IPython Code Mode. +- A loadable maple_gpui Python controller with Read Only and Full Access policies. +- Later, declarative generated companion views and mini-apps. + +The current branch must implement the real vertical spine, not merely types or mock UI. It is acceptable for long-tail compatibility and hardened process isolation to remain explicit follow-up work. It is not acceptable to leave the central action path, policy checks, cancellation, stable semantic targeting, core Vim commands, Python lifecycle, controller SDK, or live validation as TODOs. + +## 2. Document and review contract + +This document began as the implementation brief for the Developer Preview and +now serves as its design and implementation record. The implementation is +intentionally end to end so reviewers can evaluate the real interaction among +actions, policy, semantic state, Vim, Python, and model control rather than a +set of disconnected abstractions. + +The review contract is: + +1. Treat the product thesis, dependency direction, one-path invariant, + authority model, honest Python boundary, and fail-closed lifecycle as the + architectural center of the proposal. +2. Keep semantic behavior on one typed action path. Do not accept a + metadata-only registry while buttons, shortcuts, or model calls bypass it. +3. Preserve stable wire identities and semantic targets across Rust/GPUI + refactors; do not expose GPUI entities, callbacks, or Rust type names as the + public automation protocol. +4. Require direct-human provenance for Human Only actions and authority + changes. A controller request cannot claim its own actor, transport, + account, task, policy epoch, or capability lease. +5. Treat native Python as a separate process and a useful Developer Preview, + not as a hardened sandbox. Production containment remains an explicit + follow-up. +6. Keep every change buildable and covered at the owning layer. Run the full + repository validation before proposing promotion beyond Developer Preview. +7. Review the implementation in the architectural slices in Section 26 even + when integration and follow-up fixes require additional commits. + +## 3. Quality bar + +This is an ambitious experiment, not a throwaway prototype. + +The desired balance is: + +- Complete architecture and genuine end-to-end behavior. +- Good Rust boundaries and testable pure components. +- Deliberate, understandable UX. +- Honest security language. +- Clean commits and useful documentation. +- TODOs for production hardening and exhaustive edge cases. + +The following are acceptable follow-ups: + +- Exhaustive Vim compatibility. +- A hardened macOS helper or XPC sandbox and equivalent Linux/Windows isolation. +- Rich Python package and environment management. +- Durable Python variables across app restarts. +- Generated UI cards and companion surfaces. +- Dynamic third-party action registration. +- Exhaustive localization and accessibility polish. + +The following are not acceptable shortcuts: + +- Separate button, shortcut, model, and Python implementations of the same operation. +- Key or coordinate simulation as the model control API. +- An action registry that is metadata only while buttons keep bypassing it. +- Action targets based on virtual row indices or GPUI entities. +- A Read Only controller that can mutate through a generic activate command. +- A Python module that directly receives GPUI objects, backend handles, credentials, or Rust pointers. +- Python execution in the GPUI process. +- Unbounded Python output or an infinite loop without a working Stop path. +- Calling native CPython a sandbox merely because cwd and HOME were changed. +- Applying Vim interception to password, login, search, rename, or settings inputs. +- Implementing dot repeat by replaying raw keystrokes. +- Stopping after planning, scaffolding, or unit tests without launching and exercising the app. + +## 4. Product thesis and inspirations + +### 4.1 Vim as an application grammar + +Vim contributes more than familiar movement keys. Its important ideas are: + +- explicit modes; +- composable operators and motions; +- counts; +- text objects; +- repeatable semantic changes; +- command discovery through prefixes and a command line; +- a stable distinction between navigation and insertion. + +Maple applies those ideas at two levels: + +1. Application Vim navigates semantic Maple objects: tasks, projects, transcript items, settings rows, menus, questions, permissions, tool cards, and future annotations. +2. Composer Vim edits text with a true modal state machine. + +The two levels coordinate through shared profiles and context state, but they must not be one giant state machine. A transcript j moves between timeline objects. A composer j moves between logical text lines. + +### 4.2 GPUI and Zed + +The current app already uses GPUI 0.2.2 typed actions, key contexts, bubbling, focus handles, and virtualized lists. GPUI also supports: + +- typed parameterized actions and JSON schemas; +- dynamic action construction; +- multi-stroke bindings; +- boolean key-context predicates; +- clearing and rebuilding the complete keymap; +- enumerating current actions and bindings; +- observing pending multi-stroke input. + +The intended direction follows Zed's useful patterns: + +- Almost all functionality is exposed as actions. +- User keymaps bind action IDs, action arguments, sequences, and context predicates. +- Later, more specific or user bindings override templates. +- A command palette discovers and dispatches available actions. +- Vim state participates in key contexts rather than intercepting every key globally. +- Vim extends beyond the editor into panels and application surfaces. + +Useful primary references: + +- GPUI key dispatch: https://github.com/zed-industries/zed/blob/main/crates/gpui/docs/key_dispatch.md +- Zed key bindings: https://zed.dev/docs/key-bindings +- Zed Vim behavior and contexts: https://zed.dev/docs/vim +- Zed Vim keymap: https://github.com/zed-industries/zed/blob/main/assets/keymaps/vim.json +- Zed Vim engine: https://github.com/zed-industries/zed/blob/main/crates/vim/src/vim.rs +- Zed command palette: https://github.com/zed-industries/zed/blob/main/crates/command_palette/src/command_palette.rs + +Maple should borrow the architectural lessons, not copy Zed wholesale. Maple's objects are conversations, tasks, tools, permissions, and projects rather than buffers and syntax trees. + +### 4.3 Codex-style model viewpoint control + +The motivating experience is a model that can cross the application boundary in a controlled, inspectable way. + +For example, while the user is typing, an assistant response streams into the transcript. Streaming does not steal focus. In Vim: + +1. Escape leaves Insert for composer Normal. +2. ga selects the newest assistant response, or Ctrl-W k moves to the transcript. +3. left-bracket d and right-bracket d move among annotations when annotation objects exist. +4. Enter activates or opens the selected semantic object. +5. gi returns to the composer at the stored insertion point when the draft + revision still matches, or safely to the end of a replaced draft. + +The model can perform the same navigation semantically: + +~~~python +import maple_gpui as maple + +snapshot = await maple.ui.describe() +tasks = await maple.tasks.list(title_contains="Nix flake") +await maple.workspace.open_task(tasks[0].id) +await maple.transcript.focus_next(kind="annotation") +await maple.events.wait( + {"kind": "semantic_selection_changed"}, + after=snapshot.event_cursor, + timeout=10, +) +~~~ + +The model should invoke the inner action directly when changing the visible viewpoint adds no user value. It should navigate visibly when the user needs to see the object or follow the flow. Both paths use the same registry and policy. + +### 4.4 Python and future RLM work + +Python is not only an implementation detail for UI navigation. A persistent +per-task execution environment is useful for general computation and future +recursive-language-model work. In this Developer Preview it is exercised by +model `python_code` calls and inspected from Settings; there is no human-facing +chat REPL. Therefore: + +- General Python Code Mode works when UI control is Off. +- The maple_gpui SDK is a required deliverable in this branch, layered on top of general Python and importable for useful calls only when UI Controller access is Read Only or Full Access. +- The Python process is persistent per Maple task. +- IPython is preferred when available; CPython fallback still supports persistent state and top-level await. +- Code Mode is off by default and clearly labeled Developer Preview. + +### 4.5 Future generated UI + +Generated UI is deliberately not part of this branch, but the action architecture should make it possible later. + +The safe direction is a versioned declarative view document rendered from trusted GPUI components: + +- stacks and grids; +- text and Markdown; +- forms and buttons; +- tables and charts; +- images, code, and diffs; +- tabs, lists, progress, and annotations. + +Events from generated surfaces would request registered actions with schema-validated arguments. Generated surfaces would never compile arbitrary model-generated Rust into Maple's authenticated process. Trusted Maple chrome would identify generated content and generated views could never impersonate permission, credential, or account dialogs. + +Capability-limited WASM or a separate helper process could be considered later. Zed's extension capability model is useful evidence that this boundary deserves care: https://zed.dev/docs/extensions/capabilities + +Generated mini-apps are a consumer of the programmable harness, not a reason to distort the first implementation. + +## 5. Decisions already made + +These are settled unless current source makes one literally impossible: + +1. Application Vim is the priority. Composer Vim must still be complete enough to feel like Vim. +2. The first composer milestone includes dot repeat. +3. Standard and Vim are the only first-party shortcut profiles initially. +4. Selecting Vim replaces the Standard template. It is not layered on top of Standard. +5. User overrides are applied over the selected template and may replace or disable any shortcut. +6. The shortcut settings page is generated from every typed semantic action. +7. Every currently meaningful semantic button/control must migrate through the action path. +8. Pure pointer mechanics may remain local. +9. Kernel identity is per task, not global. +10. A task's Python environment may work with files in that task's opened project root and its private scratch directory. +11. Code Mode exposes no supported Maple network API and applies best-effort guardrails, while the native Developer Preview explicitly admits that the macOS-user Python process may still access the network and is not a hardened OS sandbox. +12. Python Code Mode and the Maple UI Controller are separate settings. +13. The controller modes are Off, Read Only, and Full Access. +14. Read Only permits observation and ephemeral navigation. +15. Full Access permits ordinary model-callable mutations without individual prompts. +16. Human Only actions can never be invoked by a model, Python, macros, or future generated surfaces, even when Full Access is selected. +17. Sign out and permanent account/identity/credential operations are Human Only. +18. Controller-authority and Code Mode enablement changes are direct-human-only so a controller cannot elevate itself. +19. Agent tool permission modes remain conceptually separate from UI controller modes. Per the agreed Full Access rule, permission.respond is nevertheless controller-callable in Full Access and must be conspicuously audited; Read Only cannot respond. Do not silently add a self-approval exception that was not agreed. +20. The implementation is a Developer Preview, but the architecture must be real. +21. The implementation is organized around the six review slices in Section 26; integration and follow-up fixes may be separate commits when that preserves history and buildability. +22. The initial implementation requires automated checks, a release-mode build, + and live desktop validation. Later rebases and documentation-only review + updates are revalidated in proportion to their behavioral risk. +23. Upstream review is for architectural feedback and hands-on evaluation; it does not imply that the preview is production-ready or should be merged unchanged. + +## 6. Scope + +### 6.1 Required in this branch + +- Typed semantic action descriptors and registry. +- Dynamic action availability and disabled reasons. +- Effect, recoverability, and invocation-policy metadata. +- Host-assigned actor/transport provenance. +- Schema validation for arguments and structured results. +- Stable semantic targets and optional action-specific preconditions. +- Central policy enforcement, basic audit, and cancellation. +- Migration of every current semantic control. +- Standard and Vim templates. +- Zed-style user keymap JSON, arbitrary overrides, and null unbinding. +- Context-aware multi-key bindings. +- Conflict detection and a shortcut recorder. +- Searchable Shortcuts settings generated from the registry. +- Per-command and whole-profile reset. +- Unbound-command visibility. +- A root command palette. +- A passive which-key overlay. +- Stable semantic selection for the transcript, sidebar, settings, and modal surfaces. +- Whole-app Vim navigation with the agreed command set. +- Composer Normal, Insert, and characterwise Visual modes. +- Required composer motions, operators, counts, text objects, paste, undo/redo, and dot repeat. +- A visible composer mode indicator and mode-aware cursor. +- Persistent per-task Python worker outside the GPUI process. +- IPython when available and a useful CPython fallback. +- Output, source, protocol, runtime, and active-kernel bounds. +- Stop, Reset, Restart, crash recovery, and process-tree cleanup. +- A Settings-only Code Mode configuration, status, lifecycle, retained-program, + and audit surface; no chat REPL or global HUD. +- Model-facing python_code tool. +- Required maple_gpui SDK, enabled for controller calls only in Read Only or Full Access. +- Semantic state discovery, action discovery/invocation, and event waiting. +- A trusted built-in maple-ui-controller skill. +- Settings, disclosure, audit visibility, and policy revocation. +- Focused unit/integration tests, complete repository CI, macOS release build, and live GUI validation. + +### 6.2 Explicitly deferred + +- Full Vim, Neovim, or plugin compatibility. +- Visual Line and Visual Block, named registers, marks, macros, substitutions, search grammar, and full Ex. +- Named multi-action user macros and transactional action programs. +- A hardened OS sandbox and production containment claims. +- Bundled Python distribution in every release artifact. +- Virtualenv/package-management UX. +- Durable Python namespace across app restarts. +- Broad MIME-rich Python display support. +- Generated cards, panels, or full-screen views. +- Dynamic third-party action registration and schema migration. +- Multi-window semantic navigation. +- Linux GUI validation beyond compilation/tests in this macOS-first branch. +- Retry-addressable process-cleanup ownership after a bounded worker teardown + reports diagnostics. +- Concurrent, bounded teardown-receipt capture with durable ownership across + waiter cancellation. +- Operating-system-backed physical-input provenance below GPUI's synthetic + input seam. + +### 6.3 Annotation boundary + +Codex-style annotations are an inspiration and a semantic target type. The current Maple GPUI source does not have an equivalent first-class transcript annotation transport. This branch must: + +- define stable Annotation semantic targets; +- include previous/next annotation actions and Vim bindings; +- make the behavior work in fixtures and whenever an annotation producer exists; +- return a clear unavailable reason when no annotations exist. + +It must not invent a new model annotation protocol or parse arbitrary Markdown conventions merely to make the keybinding appear active. A first-class annotation producer/transport can be a later project. + +## 7. Pre-implementation baseline + +The design was grounded against `903c377d79d77ab303309b6479f20cbcb4253c02`. +These facts explain the seams the implementation extended; they are historical +baseline context rather than a description of the completed tree. + +### 7.1 Existing root and screens + +- app/src/desktop.rs contained MapleApp and the Screen enum. +- MapleApp owned Login, Chat, Settings, and a parked Chat entity while Settings was open. +- app/src/backend.rs owned the private Tokio runtime and runtime-effect facade. +- app/src/settings.rs persisted AppSettings safely with serde defaults and a serialized background writer. +- app/src/ui/settings.rs had five fixed sections before Keyboard Shortcuts and Programmability were added. + +The completed action host and application semantic controller are rooted at +MapleApp so they operate across Chat, Settings, Login, overlays, and the parked +Chat screen. + +### 7.2 Existing GPUI actions and keymaps + +- app/src/desktop.rs declares QuitApp and hard-codes app keybindings. +- app/src/ui/chat/mod.rs declares the existing chat actions and attaches listeners to the Chat key context. +- app/src/ui/text_input.rs declares editing actions and separately installs TextInput bindings. +- Current contexts are broad: Chat, Transcript, RootMenu, and TextInput. +- PickQuestionOption is parameterized but currently no_json, so it cannot be dynamically built or discovered through a JSON schema. + +GPUI 0.2.2 already supplies App::bind_keys, App::clear_key_bindings, App::build_action, action schemas/documentation, typed dispatch, Window::available_actions, binding lookup, context stacks, and pending keystroke inspection. Window::available_actions is structural/default-buildable availability, not Maple's complete parameterized semantic catalog or business availability; the harness adds that layer explicitly. + +Important reload rule: App::clear_key_bindings clears every binding, including TextInput defaults. A profile reload must compile and reinstall the complete resolved keymap atomically. + +### 7.3 Existing stable state + +ChatScreen already has: + +- stable session IDs; +- AgentTimelineItem IDs; +- timeline_index mapping item IDs to current index and revision; +- separate transcript and sidebar ListState values; +- explicit follow_transcript state; +- selection/reload generation fences; +- per-session timeline revisions; +- stable queue IDs, request IDs, and draft image IDs in several paths. + +Both transcript and sidebar are virtualized. Off-screen rows have no durable GPUI focus handle. Semantic selection therefore must be application state keyed by stable IDs. + +### 7.4 Existing model tool seam + +MapleDeveloperClient in crates/maple-agent/src/agent/developer_tools.rs owns the built-in developer tool catalog. Its call_tool method already receives: + +- the source session ID; +- the session working directory; +- the current model run CancellationToken. + +That is the correct seam for a python_code model tool. + +A task's authoritative identity is the Goose session ID plus account scope. Its canonical project root comes from durable session metadata such as AgentSessionSummary.project_root. Never use the currently selected ChatScreen.project_root for a background task's Python kernel. + +### 7.5 Existing process-management precedent + +The bounded shell implementation already demonstrates: + +- process groups on Unix; +- job objects on Windows; +- bounded output; +- timeouts; +- cancellation; +- descendant cleanup; +- careful drain behavior. + +Reuse those patterns for Python worker supervision instead of inventing a weaker child-process lifecycle. + +## 8. Implemented module boundaries + +The dependency direction is the durable contract. This is the implemented +module map; individual files may still move without changing that contract. + +~~~text +crates/maple-harness/ + src/action.rs wire-safe action and descriptor types + src/registry.rs validated registry and descriptor lookup + src/policy.rs controller policy and host origins + src/semantic.rs semantic targets, snapshot, events, revisions + src/audit.rs bounded/redacted audit records + src/keymap.rs profile-independent keymap data and validation + +crates/maple-code-mode/ + src/interpreter.rs deterministic interpreter discovery/probing + src/protocol.rs framed worker protocol + src/process.rs child lifecycle and fixed runtime materialization + src/kernel.rs per-task actor and state + src/service.rs kernel pool and limits + src/authority.rs normalized session authority tuple + src/limits.rs protocol/output/runtime bounds + src/controller.rs UiControllerTransport trait + python/worker.py fixed worker bootstrap + python/maple_gpui/ generated/thin SDK + +crates/maple-agent/ + existing developer tool integration + Maple-owned embedded maple-ui-controller skill + +app/src/harness/ + catalog.rs descriptors and GPUI adapter registration + host.rs one executor path, policy, audit, and operations + adapters.rs typed GPUI action adapters + provenance.rs bounded direct-user input capabilities + semantic.rs application semantic controller/projection + keymap.rs Standard/Vim templates and context compilation + keymap_runtime.rs resolved-map installation and live reload + shortcut_store.rs serialized profile/override persistence + palette.rs root command palette + which_key.rs pending-chord help overlay + application_vim.rs application Vim controller + controller.rs bounded GPUI request/response bridge + +app/src/ui/ + settings/shortcuts.rs shortcut settings projection and recorder + settings/programmability.rs + Settings-only Code Mode/controller lifecycle state + chat/code_mode.rs retained command/lifecycle integration; not mounted + as a chat REPL or HUD + text_input/vim.rs pure composer Vim engine +~~~ + +Rules: + +- maple-harness has no GPUI dependency. +- maple-code-mode has no GPUI, account client, credential store, or action implementation. +- maple-agent does not depend on app. +- app implements the final GPUI/action host and the UiControllerTransport. +- Python never imports or loads Rust/GPUI internals. +- The external protocol and Python SDK use stable semantic action IDs, not Rust type names. + +## 9. Typed semantic action system + +### 9.1 Stable IDs + +Use validated lowercase dotted IDs that survive Rust refactors: + +- app.quit +- settings.open +- task.new +- task.open +- task.set_archived +- project.set_pinned +- sidebar.set_collapsed +- transcript.focus_next +- timeline.set_tool_expanded +- composer.send +- permission.respond +- account.sign_out + +Do not expose names such as chat::NewTask as the permanent Python/model protocol. The registry maps a stable external ID to its GPUI adapter and one executor. + +ID validation should reject empty segments, uppercase characters, whitespace, and ambiguous aliases. Descriptors have a schema version and may later carry deprecated stable-ID aliases. + +### 9.2 Core types + +The implementation should express at least these concepts: + +~~~rust +pub struct ActionDescriptor { + pub schema_version: u16, + pub id: ActionId, + pub label: String, + pub description: String, + pub category: String, + pub argument_schema: serde_json::Value, + pub result_schema: serde_json::Value, + pub contexts: Vec, + pub effect: ActionEffect, + pub invocation_policy: InvocationPolicy, + pub recoverability: Recoverability, + pub audit: AuditSpec, + pub default_bindings: DefaultBindings, +} + +pub enum ActionEffect { + Observe, + Navigate, + MutateMaple, + ExternalEffect, +} + +pub enum InvocationPolicy { + ControllerCallable, + HumanOnly, +} + +pub enum Recoverability { + Ephemeral, + Reversible, + Irreversible, +} + +pub struct ActionCall { + pub action_id: ActionId, + pub arguments: serde_json::Value, + pub target: Option, + pub precondition: Option, +} + +pub struct ActionPrecondition { + pub domain: RevisionDomain, + pub target_revision: u64, +} + +pub struct ActionResponse { + pub invocation_id: InvocationId, + pub action_id: ActionId, + pub status: ActionStatus, + pub result: Option, + pub error: Option, + pub state_revision: Option, +} +~~~ + +Action errors must be structured: + +- unknown_action; +- invalid_arguments; +- not_applicable; +- unavailable; +- policy_denied; +- stale_target; +- cancelled; +- failed. + +An unavailable response includes a stable reason code and human-readable explanation. Current handlers often silently return while busy or when a target is absent; that is insufficient for a command palette or model. + +### 9.3 Trusted invocation context + +The wire request contains only ActionCall. It cannot choose its own actor, transport, controller mode, account, task, program/run, cancellation token, or policy epoch. + +The host constructs a non-deserializable invocation: + +~~~rust +pub struct TrustedInvocation { + pub invocation_id: InvocationId, + pub source_task: Option, + pub program_id: Option, + pub model_run_id: Option, + pub actor: InvocationActor, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub cancellation: CancellationToken, +} + +pub enum InvocationActor { + DirectUser, + Model, + UserCode, + Internal, +} + +pub enum InvocationTransport { + Pointer, + Keybinding, + CommandPalette, + Python, + Macro, + GeneratedUi, + Internal, +} +~~~ + +Actor and transport are separate because model-via-Python and a future trusted +manual Python surface have different provenance while sharing a transport. +Model-triggered Python has actor Model and transport Python. The reserved +UserCode actor does not gain DirectUser authority. Human Only means actor +DirectUser through a private, approved Pointer, Keybinding, or CommandPalette +entry point—not code that claims a human asked it to act. + +Never infer DirectUser merely because a GPUI action handler ran. App::dispatch_action is callable programmatically and GPUI handlers do not prove physical input. Only private UI-host entry points handling an actual pointer event, a resolved physical key event, or explicit command-palette activation may mint a direct-user invocation token. Adapter constructors and those tokens are not public to model, Python, macros, generated UI, or arbitrary internal callers. Add an adversarial test proving programmatic GPUI dispatch cannot invoke Human Only. + +Multi-stroke key resolution needs provenance even when GPUI dispatches a shorter complete binding after its prefix timeout and no physical event is currently on the stack. The window host creates a non-forgeable, window-scoped PendingKeyProvenance token on the first physical keystroke, carries it only through GPUI's current pending sequence, and consumes it when the resolved keybinding adapter dispatches. Focus/context change, explicit cancellation, timeout with no resolved binding, recorder takeover, or sequence completion clears it. A timeout-resolved shorter binding consumes the surviving token and remains DirectUser/Keybinding; App::dispatch_action without that private token does not. + +Internal is not inherently privileged. Derived/internal follow-up actions inherit the initiating actor, transport, controller access, policy epoch, cancellation, program ID, and model run ID. Truly autonomous maintenance operations require a narrow compile-time allowlist and cannot resolve or activate user-selected Human Only actions. + +The Human Only allow matrix is exhaustive: only (DirectUser, Pointer), (DirectUser, Keybinding), and (DirectUser, CommandPalette) qualify. Reject every other actor/transport tuple, including every Internal combination. Autonomous allowlisted maintenance never invokes or indirectly activates Human Only. + +### 9.4 One-path invariant + +The semantic dispatcher owns the only action executor. + +~~~text +button ----------------------+ +typed GPUI key action -------+ +command palette -------------+--> semantic dispatcher --> executor +model/Python request --------+ +future generated surface ----+ +~~~ + +GPUI actions are typed transport adapters into the dispatcher. They are not a parallel business-logic implementation. Maple's semantic registry enriches GPUI actions; it does not compete with GPUI's action catalog. Every bindable semantic action has a one-to-one typed GPUI adapter whose stable external identity maps to the semantic descriptor, and each adapter delegates to the same executor. Do not replace this with one generic Invoke(String, Value) GPUI action: per-action schemas, contexts, keymap discovery, and documentation must remain visible. + +GPUI is the typed input adapter, not the result channel. The semantic executor owns ActionResponse and asynchronous completion. Human GPUI dispatch may discard a response after audit and UI error handling; controller calls reach the same executor through a bounded request/oneshot bridge. Do not infer operation completion from Window::dispatch_action's return. + +Examples of existing divergence that must disappear: + +- The New Task shortcut and sidebar New Task button currently reach new_session through different callbacks. +- The Toggle Sidebar shortcut and header button mutate the same state through different code. +- The Open Settings shortcut and sidebar gear emit through different callbacks. +- Settings controls directly toggle fields without a discoverable typed action. + +Buttons should dispatch a typed semantic action or call the same dispatcher with a host-created DirectUser/Pointer context. The keymap compiler maps stable action IDs and typed arguments to GPUI actions. Model and Python calls validate the same argument schema and enter the same executor directly. + +### 9.5 Parameterized actions + +Every bindable/programmatic parameterized action must support serde deserialization and a JSON schema. Remove no_json from actions such as PickQuestionOption when they enter this system. + +Result schemas matter too. A model program needs to know whether task.new returns a task ID, settings.open returns a semantic screen target, or an async action returns an accepted run identifier. + +### 9.6 Setters over toggles + +Programmatic actions should be deterministic: + +- task.set_archived with task_id and archived; +- project.set_pinned with root and pinned; +- sidebar.set_collapsed with collapsed; +- composer.set_expanded with expanded; +- task.set_web_enabled with task_id and enabled; +- mcp.set_enabled with server_id and enabled. + +Human convenience toggle actions may remain, but should resolve current state and delegate to the setter. Python/model flows must not rely on stale toggles. + +### 9.7 Availability + +Each action has a side-effect-free availability function evaluated against current semantic state and arguments. It returns: + +~~~rust +pub enum Availability { + Available, + Disabled { + code: DisabledReasonCode, + message: String, + }, +} +~~~ + +Availability is not authority. An action can be applicable but denied by policy. It is also not GPUI structural availability alone. A task action may be available for an off-screen stable target even though no row is rendered. + +### 9.8 Revisions and asynchronous effects + +State snapshots and semantic events carry monotonically increasing global revisions for snapshot coherence and event sequencing. Programmatic calls may also supply an action-declared target revision or precondition returned with the queried target. Compare only the revision domain relevant to that action—such as a task record, draft, setting, or timeline item. Do not reject an unrelated task-open or settings call merely because a streaming transcript advanced the global state revision. If the relevant target changed, return stale_target rather than operating on a surprising object. + +For async actions: + +- accepted is not completed; +- the response must expose a run/invocation ID when work continues; +- audit completion occurs when the actual result is known; +- cancellation may prevent later stages but does not pretend to reverse completed effects; +- a policy lease is rechecked immediately before a deferred effect commits. + +### 9.9 Registry validation + +Startup/tests must fail for: + +- duplicate or invalid stable IDs; +- missing labels/descriptions; +- bindable actions without argument schemas; +- missing result schemas for controller-callable results; +- missing GPUI adapters; +- keymap references to unknown IDs; +- default bindings that cannot be parsed; +- unsafe audit defaults on secret-bearing actions. + +## 10. Authority model + +### 10.1 Controller modes + +UiControllerAccess is a new type and must not reuse the existing agent PermissionMode. + +| Controller mode | Observe | Navigate | Mutate Maple | External effect | Human Only | +|---|---:|---:|---:|---:|---:| +| Off | deny | deny | deny | deny | deny | +| Read Only | allow | allow | deny | deny | deny | +| Full Access | allow | allow | allow | allow | deny | + +Direct-user pointer, keybinding, and command-palette invocations are not constrained by the controller mode. They still obey ordinary action availability. + +Full Access intentionally has no per-action confirmation prompts for ordinary controller-callable actions. It is meant to let the model chain useful workflows. The permanent Stop control and audit provide visibility and revocation. + +### 10.2 Initial Human Only set + +At minimum: + +- account.sign_out; +- account deletion; +- email, password, recovery, MFA, credential, token, and session-revocation operations; +- authentication submission/confirmation operations; +- controller mode and Code Mode authority changes; + +Controller and Code Mode authority changes are direct-human-only because: + +- a Read Only controller must not promote itself to Full Access; + +Settings and MCP mutations are ordinary Full Access actions. Sending a message +to another task is an ordinary Full Access action. Viewing or navigating tasks +is Read Only. `permission.respond` is also an ordinary Full Access mutation, +including for a request associated with the originating model run. This is an +explicit product-policy choice for review: permanent account/identity +operations and authority changes remain Human Only. Keep the agent permission +setting separate, display and audit controller-originated responses clearly, +and do not invent a self-approval prohibition without a later product decision. + +app.quit remains controller-callable in Full Access. It is disruptive but not a permanent account-level effect. Mark its descriptor as a terminal host action. Commit the accepted audit record, queue the terminal SDK response, stop new admission, and only then begin orderly shutdown. The caller may observe accepted_terminal rather than a normal post-shutdown completion. Cancellation before admission prevents it; once shutdown is committed it is not reversible. + +### 10.3 Final-action reauthorization + +Generic actions such as ui.activate_selected are useful, but they are never authority shortcuts. + +The dispatcher must: + +1. Resolve the selected semantic object. +2. Resolve its concrete backing action and arguments. +3. Re-evaluate availability. +4. Re-read the current controller policy and epoch. +5. Apply the concrete action's Human Only/effect policy. +6. Invoke the concrete executor. + +Selecting Sign Out and then calling ui.activate_selected must still be denied to Python/model origins. + +### 10.4 Policy revocation + +Policy is revisioned. + +- Turning Python Code Mode Off cancels active executions and stops kernels. +- Downgrading Full Access to Read Only revokes pending mutation leases. +- Turning the controller Off revokes all SDK requests and event waits. +- A previously imported maple_gpui module remains powerless because Rust checks current policy on every request. +- A stale model tool call offered before settings changed fails closed at execution time. + +## 11. Semantic control migration + +There are roughly seventy-six current on_click sites. Not all are semantic, but every discoverable, bindable, auditable, or programmatically useful result must enter the registry. + +### 11.1 Initial catalog + +| Area | Required action family | +|---|---| +| App/screens | app.quit, settings.open, settings.close, settings.open_section, shortcuts.open, code_mode.open | +| Account/auth | account.sign_out and current sign-in/OAuth operations as Human Only | +| Tasks | task.new, task.open, task.rename, task.set_archived, task.focus_previous, task.focus_next | +| Projects | project.choose, project.switch, project.set_collapsed, project.set_pinned, project.rename, project.reveal, project.remove, project.set_trusted | +| Sidebar | sidebar.focus, sidebar.set_collapsed, sidebar.clear_search, sidebar.set_archived_expanded | +| Transcript | transcript.focus, focus_next, focus_previous, focus_first, focus_last, copy_selection, select_all | +| Timeline | timeline.copy_item, timeline.set_tool_expanded, timeline.open_attachment, timeline.toggle_speech | +| Composer | composer.focus, set_text, send, steer, set_expanded, attach_files, remove_attachment, set_model, set_permission_mode, set_web_enabled, set_mcp_enabled, toggle_recording | +| Text input | every Standard/Vim keymap primitive, including movement, selection, deletion, clipboard, undo/redo, newline, and submit actions | +| Composer Vim | typed command families for motion, operator, count digit, text object, insert entry, Visual, paste, undo/redo, repeat, and cancel | +| Runs/queue | run.stop, queue.steer, queue.begin_edit, queue.remove, queue.discard_edit | +| Questions | question.select_option, question.submit, question.skip | +| Permissions | permission.respond as a Full Access mutation; selection remains Read Only | +| Settings | explicit setter for every persisted preference; prompt save/reset; MCP add/update/set_enabled/remove | +| Utilities | ui.dismiss, ui.reveal, link.open, clipboard.copy | +| Code Mode | code_mode.execute, stop, reset, restart, clear_scratch, set_enabled, set_controller_access | + +The agent must inventory current controls against this table and current source, not assume the table is exhaustive if upstream added a control. Every GPUI action referenced by Standard, Vim, or a user keymap—including TextInput primitives and composer Vim command tokens—has a semantic descriptor and appears in shortcut discovery. Focus-local editor primitives may be Human Only and shortcut-only rather than controller-callable, but they cannot live in an undocumented second action universe. + +### 11.2 Stable action arguments + +Never expose: + +- sidebar or timeline vector indices; +- GPUI Entity or FocusHandle values; +- render-only element IDs; +- the currently visible card as an implicit model target; +- draft attachment array indices. + +Use: + +- task/session IDs; +- canonical project roots or stable project IDs; +- timeline item IDs; +- permission/question/request IDs; +- queue IDs; +- DraftImage.id; +- stable settings keys; +- menu IDs plus item IDs. + +### 11.3 Local gesture allowlist + +These may remain local mechanics: + +- drag-selection position updates; +- mouse hit testing; +- hover; +- scrollbar/wheel movement; +- layout measurement; +- cursor painting; +- event propagation wrappers. + +The semantic result still becomes an action. For example, drop hit testing is local, but attaching the resulting file list is composer.attach_files. A backdrop click is local input, but its result is ui.dismiss. + +Keep a checked-in control inventory with, for every inspected control, its source path/symbol, stable action ID and executor, or explicit local-gesture exemption reason. Add a source-audit test/script which inventories direct semantic on_click and comparable activation callbacks in the relevant UI modules and fails on an unexplained site. The script need not solve arbitrary Rust dataflow; a required adjacent semantic-action/local-gesture marker plus registry validation is sufficient. Review fails on an unexplained direct semantic callback. + +Registry tests must also prove stable action ID to App::build_action to the typed GPUI adapter to the semantic executor. GPUI all_action_names enumerates registered action types and Window::available_actions describes structurally available/default-buildable actions; neither is, by itself, the complete parameterized Maple semantic catalog. + +### 11.4 Root ownership + +MapleApp should own or coordinate the dispatcher because it can route to: + +- current ChatScreen; +- parked ChatScreen while Settings is open; +- SettingsScreen; +- LoginScreen; +- root overlays; +- application shutdown. + +Screen-specific executors can remain methods on those entities. They are reached only through the root dispatch table. + +### 11.5 Native argument acquisition + +Actions that need a human-selected path still use the same semantic action +path. `project.choose` and an empty-path `composer.attach_files` request acquire +their arguments through GPUI's native directory or multi-file picker inside +the owning executor; a cancelled picker completes as a successful no-op. +Explicit controller-supplied paths do not open a picker. Project selection may +fall back to the existing Linux portal/manual-entry flow when the native +directory picker is unavailable. Picker acquisition does not bypass policy, +provenance, operation completion, or cancellation barriers. + +## 12. Semantic UI model + +### 12.1 Five independent concepts + +Do not collapse these into one focus field: + +1. GPUI keyboard focus: which rendered element receives events. +2. Semantic selection: which stable application object is selected by application Vim or model navigation. +3. Text insertion/selection: byte/grapheme positions in a TextInput or rich-text selection. +4. Viewport: which virtual rows happen to be visible. +5. Stream follow: whether appended transcript output keeps the viewport at the bottom. + +Streaming may update a selected timeline item's revision without changing its semantic ID. Revealing a semantic selection may move the viewport without changing text insertion. Focusing a TextInput may move GPUI focus without selecting a transcript item. + +### 12.2 Semantic target types + +Use a serializable enum internally and on the controller wire: + +~~~rust +pub enum SemanticTarget { + App, + Screen { screen: ScreenId }, + Region { region: RegionId }, + Project { canonical_root: String }, + Task { task_id: String }, + TimelineItem { task_id: String, item_id: String }, + Annotation { + task_id: String, + item_id: String, + annotation_id: String, + }, + Permission { request_id: String }, + Question { + request_id: String, + question_id: String, + }, + QueueItem { task_id: String, queue_id: String }, + DraftAttachment { draft_id: u64 }, + Setting { key: String }, + MenuItem { menu_id: String, item_id: String }, +} +~~~ + +Use structural serialization on the wire. A display path such as: + +~~~text +task:/timeline:/annotation: +~~~ + +is useful for logs and UI, but should not require fragile string parsing inside executors. + +### 12.3 Semantic snapshot + +ui.describe returns a redacted, versioned projection rather than Rust state: + +~~~rust +pub struct SemanticSnapshot { + pub schema_version: u16, + pub state_revision: u64, + pub event_cursor: u64, + pub screen: ScreenId, + pub active_region: RegionId, + pub selection: Option, + pub insertion: Option, + pub viewport: ViewportSummary, + pub stream_follow: bool, + pub roots: Vec, +} + +pub struct SemanticNode { + pub target: SemanticTarget, + pub kind: SemanticKind, + pub label: Option, + pub state: SemanticNodeState, + pub available_actions: Vec, + pub children: Vec, +} +~~~ + +The snapshot may include task titles and visible transcript semantics needed for navigation. It must never include: + +- passwords; +- OAuth callbacks/tokens; +- secret MCP headers or environment values; +- raw account credentials; +- hidden permission payloads; +- database handles or paths unrelated to the current product view. + +Redaction applies in Read Only and Full Access. + +### 12.4 Semantic selection state + +Recommended application state: + +~~~rust +pub struct SemanticSelection { + pub region: RegionId, + pub target: Option, + pub anchor: Option, + pub explicit: bool, + pub observed_revision: u64, +} +~~~ + +Keep a bounded region-return stack, not one overwrite-prone previous slot. Each frame records screen, region, stable target, reason, and relevant revision. Entering a transient overlay/dialog pushes; closing it pops exactly that frame. Moving from an application region into the composer pushes the originating region once; repeated composer mode changes do not keep pushing. Composer Normal Escape pops back to that region. gi stores/restores the composer insertion separately and does not pop an unrelated overlay frame. + +If a stored target no longer exists, restore the region and its nearest valid selection. With no prior application region, composer Escape falls back to Transcript in Chat. gi is unavailable outside Chat. Its per-task insertion point carries the draft revision; after draft/task replacement, validate and clamp it to a grapheme boundary, or fall back to the end of the current draft rather than using a stale byte offset. + +### 12.5 Virtualized transcript rules + +Store transcript selection as task ID plus timeline item ID, never as row index. + +Each region exposes one ordered navigable-child projection separate from its raw backing collection. j/k/gg/G and semantic query use that projection. A child is eligible only when it has a stable target and a visible/revealable rendering with nonzero semantic presence. Skip internal todo/state items, filtered objects, empty placeholders, zero-height rows, and implementation-only records unless they deliberately render an accessible semantic object. Ordering matches visible application order. "Newest" means the last eligible projected object; "newest assistant" means the last eligible assistant message object, including a currently streaming assistant message once its semantic row exists. Tests must include hidden and zero-height backing items so selection can never land on an object with no highlight or reveal target. + +Resolve through timeline_index immediately before: + +- reveal; +- copy; +- activate; +- move to neighboring item; +- find annotations/assistant items. + +Reconciliation: + +- Same item ID with a newer content revision: retain selection. +- Items inserted before the target: retain target and resolve its new index. +- Selected item removed: choose the nearest surviving neighbor using the previous ordered-ID list; then fall back to the region itself. +- Task switch: restore that task's prior semantic selection if still valid. +- Selecting away from newest: set follow_transcript false. +- Explicit G or jump-to-latest: select/reveal newest and re-enable follow. +- New chunks never implicitly re-enable follow. +- Filtering may hide a valid target without deleting it; reveal may explicitly clear the filter. + +Do not splice/re-measure the newest streaming item in a way that regresses the repository's existing wheel-scroll protection. + +### 12.6 Sidebar, settings, menus, questions, and permissions + +- Sidebar task rows resolve by task ID. +- Project headers resolve by canonical root. +- Settings rows resolve by stable setting/action key. +- Menus resolve by stable menu ID plus item ID. +- Questions and permissions resolve by request IDs. +- Selecting a permission is Read Only navigation. +- Responding to it is a separate mutation: direct users may invoke it, Read Only cannot, and Full Access controllers may invoke it under the explicit policy in Section 10.2. + +### 12.7 Semantic events + +Expose a bounded, sequence-numbered event stream: + +- screen_changed; +- region_changed; +- semantic_selection_changed; +- target_updated; +- target_removed; +- task_opened; +- action_started; +- action_completed; +- controller_policy_changed; +- code_mode_state_changed. + +events.wait accepts a predicate, an after cursor, and a timeout. This prevents the classic query-then-wait race: + +1. ui.describe returns event_cursor N. +2. Invoke an action. +3. Wait after N. + +If the bounded event buffer overflows, return a resync_required error and require a fresh ui.describe. Do not encourage polling loops. + +## 13. Shortcut profiles and keymap format + +### 13.1 Profile semantics + +Use these first-party profiles: + +- Standard +- Vim + +The resolver is: + +~~~text +selected Standard OR Vim template + | + v + user overrides + | + v + one complete GPUI keymap +~~~ + +Do not install Standard and then overlay Vim. Vim replaces Standard so its modal grammar does not fight ordinary shortcuts. Replacement does not mean ordinary controls stop working: both templates independently include the complete essential keymap for every non-composer input role. If Vim wants Secondary-C in Insert, or ordinary editing in search/login/rename/settings, those bindings must be explicit in the Vim template. + +Changing profile must: + +1. Parse/validate the target template and overrides. +2. Build a complete resolved map, including TextInput essentials. +3. If valid, clear and reinstall all GPUI bindings atomically. +4. Update key contexts and modal state. +5. If invalid, keep the last-known-good map and show errors. + +### 13.2 Persistence + +Persist the selected profile and simple preferences in settings.json: + +~~~json +{ + "keymap_profile": "vim", + "vim_leader": "space" +} +~~~ + +Keep editable overrides under the platform config root documented in README: + +~~~text +/keymap.json +~~~ + +Use a Zed-style JSON array: + +~~~json +[ + { + "context": "MapleApp && profile == vim && region == transcript && app_vim_mode == normal", + "bindings": { + "j": "transcript.focus_next", + "k": "transcript.focus_previous", + "space s n": "task.new", + "secondary-k": null, + "ctrl-enter": [ + "composer.send", + { "steer": true } + ] + } + } +] +~~~ + +Rules: + +- A string is an action with default/no arguments. +- An array is a stable action ID plus typed argument object. +- null disables that sequence in the matching context. +- A sequence is space-separated GPUI keystrokes. +- Context is a GPUI-compatible boolean predicate. +- Unknown fields and invalid action arguments produce surfaced diagnostics. +- Never silently rewrite or discard an invalid hand-edited file. +- Settings UI edits write atomically and preserve a useful formatted representation. + +### 13.3 Precedence and conflict model + +Within one active context: + +- More specific/deeper matching contexts win. +- At equal specificity, later user entries win. +- User overrides load after the selected template. +- null is a real high-precedence unbinding. + +The resolver retains a ResolvedBinding side table with: + +- source template/file and source location; +- context string and parsed predicate; +- key sequence; +- stable action ID and arguments; +- default binding; +- effective/shadowed/disabled state; +- exact/prefix/possible conflicts. + +Conflict detection covers: + +- identical sequences with overlapping contexts; +- one sequence being a prefix of another; +- duplicate user entries; +- invalid IDs or arguments; +- impossible action/context combinations; +- deliberate user shadowing of a template. + +Arbitrary context-overlap proof is difficult. When disjointness cannot be proven, report a possible conflict instead of pretending certainty. + +Prefix bindings are valid. Follow GPUI/Zed behavior: wait briefly for a longer sequence when a shorter complete binding is also a prefix. + +### 13.4 Context tree + +Enrich the existing contexts. A representative tree is: + +~~~text +MapleApp os=macos profile=vim screen=chat app_vim_mode=inactive + Chat + Sidebar region=sidebar app_vim_mode=normal popup=none + Transcript region=transcript app_vim_mode=normal popup=none + Composer region=composer app_vim_mode=inactive + TextInput input_role=composer editor_vim_mode=insert + Overlay overlay=command_palette + Dialog dialog=question +~~~ + +Other input roles include: + +- password; +- login; +- search; +- rename; +- settings; +- prompt; +- question; +- mcp_editor; +- code_editor. + +Application and editor modes are separate keys. Never reuse a broad vim_mode=normal predicate for Sidebar, Transcript, and composer Normal. app_vim_mode is normal only while the application controller owns unmodified navigation; editor_vim_mode is normal/insert/visual only on the opted-in composer. Overlays and dialogs add higher-priority contexts. Do not depend on render-updated key-context fields for operator/count/text-object grammar between rapid keystrokes; the focused editor action handler resolves contextual command tokens directly against its current VimState. + +Only input_role=composer receives the composer Vim engine in the first implementation. Context-specific application bindings must not leak into other inputs. Every ordinary input role retains its complete TextInput essentials while the Vim profile is active, including cursor movement, selection, deletion, clipboard, undo/redo, IME, submit/cancel, and its surface-specific keys. + +### 13.5 Standard template + +Preserve current ordinary behavior, including platform modifier variants and TextInput editing. Add discoverable defaults such as: + +- Secondary-Shift-P: command palette (Cmd-Shift-P on macOS, Ctrl-Shift-P elsewhere). +- Secondary-K Secondary-S: shortcut settings. +- Existing New Task, search, sidebar, archived, settings, project, permission, transcript copy/select-all, menu, and TextInput bindings. + +The exact existing shortcuts should be migrated, not casually changed. The Standard template includes ordinary TextInput essentials for all input roles. + +### 13.6 Vim template + +The Vim profile has its own complete template: + +- Application Normal commands described below. +- Composer modal bindings described below. +- Explicit platform editing bindings in Insert where desired. +- Colon command palette. +- Space leader. +- Ctrl-W region movement. +- Ordinary TextInput essentials for input_role != composer, independent of the Standard template. + +GPUI resolves every remappable stroke and static multi-stroke chord. Application Vim owns semantic selection and count state, but it does not run an independent raw g/bracket/leader chord parser. Composer Vim receives typed command/token actions produced by the effective keymap; it does not hard-code physical h/j/k/l/d/etc. Count digits are typed tokens; the state-aware editor resolver applies the context-sensitive 0 rule synchronously, so the shortcut registry can display and remap them without waiting for a render. The only raw composer interception is the final safety/IME guard that prevents an unmatched printable key from inserting in Normal/Visual and cancels invalid pending grammar. + +Secondary-J/Secondary-K may be user-configurable aliases but are not foundational defaults. The core spatial grammar is Ctrl-W h/j/k/l. This avoids importing tmux shortcuts literally and keeps key choices editable. + +## 14. Shortcut settings UX + +Add a Keyboard Shortcuts settings section generated from the semantic registry. + +### 14.1 Page layout + +Header: + +- Search field accepting label, stable ID, description, category, or keystroke. +- Profile dropdown: Standard or Vim. +- Current state: Default or Modified. +- Reset Profile. +- Open keymap.json. +- Filters: All, Conflicts, Modified, Unbound. + +Each action row shows: + +- human label; +- stable action ID; +- description; +- category; +- supported semantic contexts; +- effect classification; +- current bindings; +- selected-template defaults; +- conflict badges; +- unbound/disabled status; +- unavailable reason when evaluated in the current screen; +- Record/Add; +- Disable; +- Reset action. + +Parameterized actions show their bound arguments. The page itself is keyboard navigable and participates in application Vim. + +### 14.2 Shortcut recorder + +The recorder: + +- activates only after a direct-user click/command; +- intercepts keystrokes and stops normal dispatch while recording; +- records a bounded multi-stroke sequence; +- displays the sequence live; +- Enter commits; +- Escape cancels; +- Backspace removes the latest stroke; +- Clear creates a null override when requested; +- shows exact, prefix, and possible conflicts before commit; +- lets the user replace, keep both with a narrower context, or cancel. + +Use GPUI keystroke interception. Do not make the recorder a global permanent interceptor. + +### 14.3 Reset behavior + +- Reset action removes overrides affecting that action and reveals the current template binding. +- Disable writes an explicit null mapping. +- Reset Profile removes all overrides only after direct-user confirmation in the settings UI. +- Switching templates does not delete user overrides; inactive-context overrides remain visible. + +Named custom profiles can wait. Standard/Vim plus arbitrary overrides are sufficient initially. + +## 15. Command palette and which-key + +### 15.1 Root command palette + +This is distinct from the current composer slash-command palette. + +It searches: + +- action labels; +- stable IDs; +- descriptions; +- categories; +- current keybindings; +- optional Ex-style aliases. + +It shows: + +- current availability and disabled reason; +- action effect/recoverability; +- current shortcut; +- inferred semantic target; +- argument UI for the small number of actions that cannot infer arguments. + +Invocation is a trusted DirectUser/CommandPalette call to the same dispatcher. + +Defaults: + +- Secondary-Shift-P in Standard. +- colon in application Vim Normal. + +The first colon layer can provide aliases such as: + +- settings; +- shortcuts; +- task new; +- project open; +- code; +- quit. + +This is not a promise of a full Vim Ex parser. Colon primarily opens and filters the real action palette. + +### 15.2 Which-key + +Compile effective bindings into a prefix trie. + +When GPUI reports pending multi-stroke input: + +- wait roughly 250 to 400 ms so fast sequences do not flash; +- filter continuations against the current context stack; +- show next keys, labels, and action IDs; +- include the configurable Space leader; +- disappear on completion, cancellation, focus/context change, or timeout. + +The first implementation is a passive preview. GPUI exposes pending input but not a public arbitrary clear operation; do not replace GPUI's dispatcher with a clickable which-key engine. + +## 16. Application Vim + +### 16.1 Ownership + +Application Vim is a controller over semantic actions. It does not synthesize keys and does not reuse the composer editing engine. + +It owns: + +- active semantic region; +- selected semantic target; +- bounded region-return stack; +- count prefix; +- current application mode/context. + +GPUI owns remappable single- and multi-stroke resolution for g, bracket, Ctrl-W, and leader sequences. Application Vim consumes the resulting typed semantic actions. GPUI focus moves only as necessary for keyboard routing. Selection and viewport remain separate. + +### 16.2 Required navigation + +| Intent | Default Vim command | +|---|---| +| Previous/next semantic item in region | k / j | +| First/last semantic item | gg / G | +| Newest assistant response | ga | +| Previous/next assistant response | left-bracket a / right-bracket a | +| Previous/next annotation | left-bracket d / right-bracket d | +| Return to composer last insertion point | gi | +| Move among adjacent regions | Ctrl-W h/j/k/l | +| Search active surface | slash | +| Open action command line/palette | colon | +| Open application command namespaces | configurable Space leader | +| Activate/open selected object | Enter | +| Collapse/parent or expand/child where meaningful | h / l | +| Close top overlay or return to containing surface | Escape | + +Counts apply to j/k and semantic next/previous families where useful. + +The d annotation mnemonic deliberately follows Vim's diagnostic navigation convention. ga is an explicit Maple mnemonic for the newest assistant item. + +### 16.3 Region behavior + +Transcript: + +- j/k move among semantic timeline objects, not rendered lines. +- h/l collapse/expand tool/reasoning/details where applicable. +- Enter opens/activates the selected object. +- y copies canonical visible text where a copy action exists. +- G selects/reveals newest and re-enables stream follow. + +Sidebar: + +- j/k move among task and project rows. +- h collapses or moves to parent. +- l expands or moves to child. +- Enter opens the task/project. + +Settings: + +- j/k move rows. +- h/l may change segmented options only when that row declares such behavior. +- Enter activates the row or enters its control. + +Menus/questions/permissions: + +- j/k move options. +- Enter activates the selected safe/direct-user action. +- Escape closes or returns. +- Permission responses remain separate mutations; Full Access controller behavior follows Section 10.2 and is conspicuously audited. + +Composer: + +- composer-specific Normal commands edit text. +- Ctrl-W movement or ga leaves the composer region through application actions reserved in the composer-Normal keymap context. +- a second Escape from composer Normal returns to the previous application region. + +### 16.4 Leader defaults + +Space is the default leader and is configurable. Initial namespaces should be small and discoverable: + +- Space s: tasks; +- Space p: projects; +- Space a: agents/assistant/annotations; +- Space m: MCPs; +- Space comma: settings; +- Space question-mark: which-key/help. + +Examples such as Space s n for task.new are appropriate. Do not fill every possible sequence merely for completeness; the registry and which-key should make additions easy. + +### 16.5 Streamed-response experience + +When output streams while the composer is active: + +- GPUI insertion focus does not move. +- Composer cursor/selection does not move. +- An explicit transcript selection does not move. +- If the viewport was following newest content, it may continue following. +- If the user navigated away, chunks do not repin it. +- ga explicitly selects the newest assistant object. +- gi returns to the composer and enters Insert at its stored insertion point + when the draft revision still matches, or at the end of a replaced draft. + +### 16.6 Application object operators + +Do not assign generic d/c/p mutations across application objects until each region has an explicit safe meaning. For this milestone: + +- navigation, activation, reveal, and copy are required; +- mutations remain named actions available through palette/leader/keymap; +- a future dd for task archive must call task.set_archived and preserve its action policy. + +This avoids making d mean deletion on one screen, archive on another, and permission denial on a third. + +## 17. Composer Vim + +### 17.1 Scope and integration + +Implement a pure editor engine in app/src/ui/text_input/vim.rs. TextInput holds Option. + +Provide an opt-in builder and live setter: + +~~~rust +TextInput::new(...).vim_enabled(true); + +input.set_vim_enabled(enabled, cx); +input.vim_mode() -> Option; +input.vim_status() -> Option; +~~~ + +Only the main chat composer opts in initially. Password, login, sidebar search, rename, question, prompt, settings, MCP editor, and other TextInput instances remain ordinary text inputs. + +Disabling Vim at runtime: + +- cancels editor count/operator/text-object pending state; +- invalidates the window PendingKeyProvenance and keymap generation so a delayed GPUI g/leader/bracket prefix adapter from the old profile is rejected even if GPUI has not exposed an explicit clear-pending API; +- exits Visual; +- commits or safely terminates any insertion transaction; +- collapses selection to a valid insertion boundary; +- restores current Standard behavior immediately. + +### 17.2 State + +Required persistent modes: + +~~~text +Disabled +Normal +Insert +Visual characterwise +~~~ + +Orthogonal/transient state: + +- count prefix; +- pending operator d/c/y; +- pending text-object prefix i/a; +- Visual anchor and head; +- unnamed register plus characterwise/linewise kind; +- preferred logical column for repeated j/k; +- current insertion undo transaction; +- last structured mutating command for dot; +- last composer insertion location for application gi. + +Use UTF-8 byte offsets at the TextInput boundary. Compute character motions on Unicode grapheme boundaries. Never split a scalar, combining sequence, or emoji cluster. + +Normal's logical cursor rests on a grapheme. Insert uses a boundary between graphemes. Visual is internally inclusive and converts carefully to TextInput's existing end-exclusive selected_range. + +### 17.2.1 Typed Vim command actions + +The pure editor is a state machine over typed commands, not raw key identities. Register descriptor-backed, remappable action families such as: + +~~~text +composer.vim.motion { motion } +composer.vim.begin_operator { operator } +composer.vim.count_digit { digit } +composer.vim.text_object_prefix { inner_or_around } +composer.vim.text_object { object } +composer.vim.enter_insert { placement } +composer.vim.open_line { above_or_below } +composer.vim.toggle_visual +composer.vim.delete_chars +composer.vim.paste { placement } +composer.vim.undo +composer.vim.redo +composer.vim.repeat +composer.vim.cancel +~~~ + +The implementation should use ordinary registry descriptors and typed GPUI action structs. Each command is visible/remappable in Shortcut Settings with its editor mode. For keys whose meaning is grammar-dependent, bind a typed contextual token and let the focused engine resolve it synchronously against current VimState in the same handler—for example i means enter-insert normally and inner after an operator, a means append normally and around after an operator, w means motion normally and Word after an i/a text-object prefix, a second d completes a linewise delete, and 0 means line-start or count digit according to stored count. Correctness must not depend on a key-context update, notification, or render occurring between physical keys. + +All countable command tokens carry no baked-in count. They consume the engine's accumulated count exactly once, defaulting to one; operator and motion counts are stored separately and multiply. Thus 3x deletes three graphemes rather than multiplying a count embedded in x, and d2d applies the operator/count grammar exactly once. + +Static multi-stroke ownership remains in GPUI. In composer Normal, the template resolves: + +- g g to the local first-line motion, consuming the editor count; +- g a to transcript.focus_newest_assistant and leaves the composer; +- g i to composer.focus_last_insertion, which enters Insert even when already in the composer; +- left-bracket/right-bracket a/d, colon, slash, Space leader, and Ctrl-W h/j/k/l to application actions; +- operator then motion/text-object as typed commands interpreted by editor state. + +Neither GPUI and the editor nor application Vim and the editor may simultaneously own the same pending prefix. Unknown or invalid input while an operator/count/text-object is pending is consumed, cancels the pending grammar, reports a short status, and never inserts text, sends, or propagates to Chat. Escape always cancels pending grammar to composer Normal. Cap a parsed count at a documented constant such as 999_999 and report overflow rather than wrapping. A leading 0 is line-start when no count exists and a digit once a nonzero count exists. d2d is linewise delete with count multiplication. + +Editor grammar has no wall-clock timeout: like Vim, a completed d/count/i-or-a prefix waits for its next semantic token until completion, Escape, invalid input, focus loss, task/draft replacement, profile change, or popup takeover. GPUI's timeout applies only to static keymap chords such as gg versus another configured g sequence. Which-key may render editor-pending grammar from VimState separately from GPUI's static pending trie. + +### 17.3 Required motions + +- h: previous grapheme in the logical line. +- l: next grapheme in the logical line. +- j: next logical line while preserving preferred grapheme column. +- k: previous logical line while preserving preferred grapheme column. +- w: beginning of the next lexical run. +- b: beginning of current or previous lexical run. +- e: end of current or next lexical run. +- 0: first column of the logical line. +- dollar: end of the logical line. +- gg: first line. +- count gg: one-based target line. +- G: last line. +- count G: one-based target line. + +Use three predictable lexical classes: + +- whitespace; +- Unicode alphanumeric plus underscore; +- punctuation/other. + +Arrow keys may alias motions while composer Vim is active. + +Normative boundaries: + +- A logical line excludes its newline separator. dollar lands on the final grapheme of a nonempty line, never on the newline or insertion boundary. On an empty line it remains at that line's stable empty Normal position. +- w, b, and e cross logical newlines as whitespace boundaries and then reach the next/previous meaningful lexical run. At document bounds they clamp/no-op. +- An empty document has one virtual empty logical line and a Normal cursor at byte boundary zero. A Normal cursor at end-of-line uses the last grapheme when one exists; the empty-line block is a visual insertion-boundary sentinel, not an invalid byte index. +- A motion returns a semantic endpoint plus characterwise/linewise and inclusive/exclusive metadata. Operators consume that metadata; do not reconstruct ranges from cursor arithmetic. + +### 17.4 Insert commands + +- i: insert before cursor. +- a: insert after cursor. +- I: first non-whitespace insertion point. +- A: logical line end. +- o: create a line below and enter Insert. +- O: create a line above and enter Insert. +- Escape: complete the insertion/change transaction and return to composer Normal. + +o/O should copy the current line's leading indentation. + +Maple-specific Enter behavior: + +- Plain Enter sends the current draft in Insert and Normal, preserving existing chat ergonomics. +- Shift-Enter inserts a newline only in Insert. +- o/O provide Vim-native newline creation without sending. + +### 17.5 Operators and counts + +Required operators: + +- d delete; +- c change and enter Insert; +- y yank to the unnamed register. + +They compose with every milestone motion. + +Repeated operators are linewise: + +- dd; +- cc; +- yy. + +Rules: + +- Operator and motion counts multiply. 2d3w affects six word motions. +- e and dollar targets are inclusive. +- w targets are exclusive. +- cw special-cases a non-whitespace starting position to the equivalent of ce, matching ordinary Vim expectations rather than consuming following whitespace. On whitespace it follows the normal c plus w target. +- j, k, gg, and G targets are linewise. +- c starts one insertion transaction after deletion. +- cc leaves one editable line and should retain leading indentation. +- dd handles first line, last line, and final newline correctly. +- yy produces a linewise register. + +Other required changes: + +- x deletes count graphemes into the unnamed register. +- p/P paste after/before for characterwise content. +- p/P paste below/above for linewise content. +- Counts apply to x, p, P, motions, operators, and dot. +- u undoes count complete Vim changes. +- Ctrl-R redoes count complete changes. + +The unnamed register is required even though full named-register compatibility is deferred. Secondary-C/Secondary-V remain system clipboard operations where the selected profile binds them. + +Linewise behavior is normative: + +- A linewise range includes complete addressed logical lines and their separator when one exists. +- Deleting/yanking a nonfinal line captures its trailing newline. Addressing the final line of a multi-line document without a final newline uses the preceding separator so no orphan blank line remains. +- dd on the only line leaves an empty document. cc on any line leaves one editable line at that location and preserves that line's leading indentation; it enters Insert at the first non-whitespace insertion point. +- After linewise delete, the cursor lands on the first grapheme of the next surviving line, or the previous final line if there is no next line, with the empty-line sentinel rule above. +- A linewise p inserts complete lines below and P above; the cursor lands on the first non-whitespace grapheme of the first pasted line. Behavior is identical whether the document originally ended in a newline. +- dw at the final word deletes to the end of that word. Immediately before a newline, it does not unexpectedly join lines unless the computed w motion actually crosses into the next lexical run; tests pin both cases. + +### 17.6 Visual + +- v enters or toggles characterwise Visual. +- Milestone motions extend the selection. +- d, x, c, and y act immediately. +- p/P replace the selection from the register. +- Escape exits Visual without changing text. +- iw/aw work as Visual targets as well as operator targets. + +Entering v selects the current grapheme immediately; on an empty document it selects the stable empty sentinel without creating an invalid range. Anchor and head are inclusive grapheme positions, so reverse selections include both endpoints when converted to TextInput's end-exclusive range. After Visual y, collapse to Normal on the former selection start. After d/x, use the deletion cursor rule. After c, enter Insert at the deletion start in the same change transaction. + +Visual p/P replaces the selection with the pre-command unnamed register and then stores the displaced selected text in the unnamed register, matching Vim's swap-like register behavior. The placement distinction does not move insertion outside the selected range for characterwise Visual replacement. Add single-grapheme, reverse-direction, combining-mark, and emoji tests. + +Visual Line, Visual Block, multiple cursors, and named registers are deferred. + +### 17.7 Text objects + +iw: + +- selects the current lexical run; +- on whitespace, selects the next meaningful run when possible; +- uses semantic boundaries, not a remembered byte range. + +aw: + +- selects iw plus trailing whitespace; +- when no trailing whitespace exists, includes leading whitespace; +- preserves predictable behavior at line/document boundaries. + +This semantic representation is necessary so ciw can repeat on a differently sized word. + +### 17.8 Dot repeat + +Dot is required and must repeat the last complete mutating semantic command. + +It must not replay raw key events. + +Required examples: + +~~~text +ciwhello then dot changes the next current word to hello +A! then dot appends ! at another logical line +3x then dot deletes three graphemes again +ohello then dot opens another line containing hello +~~~ + +Use structured recipes such as: + +~~~rust +pub enum RepeatRecipe { + Insert { + entry: InsertEntry, + inserted: InsertDelta, + }, + Operator { + operator: Operator, + target: MotionOrTextObject, + count: usize, + inserted: Option, + }, + DeleteChars { + count: usize, + }, + Paste { + placement: PastePlacement, + register: RegisterSnapshot, + count: usize, + }, + VisualChange { + operator: VisualOperator, + grapheme_span: usize, + register: Option, + inserted: Option, + }, + OpenLine { + placement: OpenLinePlacement, + indentation: IndentationPolicy, + inserted: InsertDelta, + }, +} +~~~ + +Use exactly one recipe for each change. o/O use OpenLine, not Insert, so there is no overlapping representation. Store semantic targets such as InnerWord, not original offsets. Store the committed insertion delta, not key events, so IME input, keyboard layout, and custom bindings do not alter repeat behavior. + +Visual d/c/p also update last_change. Normalize the inclusive selection to a grapheme_span independent of original direction/offset. Repeat applies the same operator to that many graphemes beginning at the new Normal cursor, using the saved pre-command register for paste and the committed InsertDelta for change. If fewer graphemes remain, clamp according to the ordinary deletion/change boundary rule; zero-span/empty-sentinel mutations that change nothing do not replace last_change. + +InsertDelta is a normalized edit script relative to the insertion entry point, not merely the final inserted string. It records ordered committed text replacements/deletions and cursor-relative edits produced during the transaction, including Backspace/Delete, selection replacement, cursor movement followed by edit, and IME committed replacement. It does not record raw physical keys or uncommitted composition. Replaying applies the normalized edits to the new semantic entry point; if a required local precondition cannot be satisfied, the repeat fails harmlessly without corrupting text. + +Additional rules: + +- count dot runs the saved recipe that many times without replacing it; +- motions, yanks, mode transitions, undo, and redo do not replace last_change; +- a no-op change does not become last_change; +- one dot command, including a count such as 2., is one undo unit for the entire counted replay; +- a failed repeat leaves last_change intact and reports a harmless status. + +### 17.9 Undo transactions + +The current TextInput snapshot/typing-run history is not sufficient for Vim composition. ciwhello Escape must undo as one change, not a deletion plus multiple insertion records. + +Add an explicit edit transaction: + +1. Begin on i/a/I/A/o/O/c. +2. Lazily record the pre-change snapshot at first mutation. +3. Suppress nested history entries while Insert continues. +4. Include the operator deletion and insertion in one transaction. +5. Commit on Escape. +6. If nothing changed, add no history entry. +7. Clear redo on a new committed edit. +8. Treat dot replay as one nested-disabled transaction. + +set_text, send/clear, disabling Vim, task replacement, and external draft restoration must deliberately terminate/reset transient Vim state. + +Use this deterministic termination contract: + +| Event during Insert/change | History | last_change | register | resulting Vim state | +|---|---|---|---|---| +| Escape | commit one transaction if mutated | update from committed semantic recipe | preserve/update only if operator changed it | Normal | +| Send or clear after successful send | commit draft edit for local history, then clear/reset task draft history | update only from the completed edit; do not make send a dot recipe | preserve | Normal on the new empty draft | +| composer.set_text or external draft restore | close current transaction without synthesizing a repeat recipe, replace text, clear undo/redo tied to the old draft | clear | preserve only if its contents are independent text; otherwise clear deliberately | Normal with clamped cursor | +| Task/screen switch | commit a real local edit into that task's draft, cancel all pending grammar, store insertion revision | keep that task's committed recipe only while its draft revision remains compatible | retain an unnamed register per task/draft; never expose it in another task | inactive until restored | +| Switch to Standard/disable Vim | commit a real edit, cancel grammar/Visual, collapse safely | retain only for restoration if the same draft revision returns; otherwise clear | preserve for same draft only | Disabled | +| Mouse click/caret move in composer | commit current insertion transaction if mutated, cancel operator/count/text-object/Visual, translate to the clicked grapheme or empty sentinel | retain the just-committed recipe | preserve the current task's register | Normal; never half-pending | + +Queue draft edit/restore follows the external-draft rule and carries a draft revision. No transient byte offset, undo entry, or dot recipe may be applied to a different task or incompatible draft revision. + +Mouse policy is intentionally Vim-like for this milestone: a direct click inside the composer places the block cursor and enters Normal, regardless of the previous editor mode. A click outside commits any active insertion transaction, leaves the editor in inactive Normal, pushes/reconciles the destination application region, and moves GPUI focus off TextInput. Double/triple-click rich selection behavior is deferred rather than leaving an ambiguous Visual/Insert state. + +### 17.10 Key interception and existing composer behavior + +Typed Vim command handlers run: + +1. after a truly modal popup has consumed its keys; +2. before ordinary TextInput actions and platform text insertion. + +In Normal and Visual: + +- consume unknown unmodified printable characters; +- do not allow them to fall into GPUI text insertion; +- make arrow/Home/End/Backspace/Delete action handlers mode-aware. + +The raw-key hook is only this unmatched-printable/IME safety guard. It must not hard-code command bindings or count digits. Remapping composer.vim.motion or composer.vim.begin_operator in keymap.json must change actual behavior, remove the old effective binding, update conflicts/which-key, and leave the pure engine unaware of the physical key. + +The existing composer on_key hook handles slash-palette movement and prompt-history Up/Down. Gate it: + +- Insert: current slash/history behavior remains. +- Normal/Visual: Vim owns printable commands and motions. +- Focused menus/questions/permissions: their modal context shadows composer Vim. + +The current Chat type-to-compose behavior must be disabled in application Vim Normal or it will turn j/k/g/leader into draft text. Preserve it in Standard. + +Escape precedence: + +- marked IME composition resolves/cancels safely first; +- Insert Escape enters composer Normal and stops propagation; +- Visual/operator Escape cancels to composer Normal; +- composer Normal Escape enters application Normal/restores prior region; +- only an unconsumed Chat-level Escape may reach existing menu-close/run-stop behavior. + +### 17.11 Mode UI + +Render a compact composer badge: + +- NORMAL; +- INSERT; +- VISUAL; +- optionally NORMAL · 3d while a count/operator is pending. + +Do not show it in Standard. + +Cursor: + +- Insert: current thin caret. +- Normal: block covering the current grapheme. +- Visual: selection highlight plus a distinct head. +- Empty/end-of-line Normal: stable minimum-width block. + +Mode-only changes must notify/render even when draft text did not change. + +### 17.12 Composer/application transition + +- Vim profile composer starts in Normal. +- i/a/I/A/o/O enter Insert. +- Insert Escape returns to composer Normal. +- Ctrl-W region motion or ga can leave composer Normal for application navigation. +- Composer Normal Escape returns to the previous semantic region. +- Application gi focuses the composer and enters Insert at the saved insertion point. + +This gives both real composer Normal mode and the fast streamed-response navigation that motivated the feature. + +### 17.12.1 Focus and routing table + +The following is normative. "Focus owner" means the GPUI element that receives the next dispatch, not merely a semantic highlight. + +| Current state/input | Consumer | Focus owner after | Semantic region/mode after | Propagation | +|---|---|---|---|---| +| Composer Insert, Escape | composer editor | TextInput | composer, editor Normal | stop | +| Composer Visual or pending operator/count, Escape | composer editor | TextInput | composer, editor Normal with pending state cleared | stop | +| Composer Normal, Escape | application transition action | prior region focus proxy, Transcript fallback | popped prior region, application Normal | stop after focus moved off TextInput | +| Application region, gi | application action | TextInput | composer Insert at revision-checked last insertion | stop | +| Composer Normal, ga | transcript action | transcript/application focus proxy | Transcript at newest assistant | stop | +| Composer Normal, Ctrl-W h/j/k/l | region action | destination region focus proxy | destination application region | stop | +| Task/screen switch | root semantic controller | new screen's region focus proxy, or TextInput only if explicitly restored | reconciled selection; transient editor grammar cancelled | stop | +| Popup/dialog opens | popup/dialog | popup/dialog focus | underlying app/editor state suspended | shadow underlying bindings | +| Popup/dialog closes | popup/dialog then return stack | exact validated prior focus proxy | prior region/editor state, with stale targets reconciled | stop | + +Before leaving composer Normal, move GPUI focus off TextInput synchronously so the next application key cannot re-enter the editor. Overlays shadow both application and composer maps. At application root, Escape reaches the existing Chat close/run-stop behavior only when no overlay, dialog, pending Vim grammar, region-return transition, or editor transition consumed it. + +Key ownership while composer Normal is focused: + +- local editor: h/j/k/l, w/b/e, 0/dollar, gg/G, operators, text objects, counts, v, x, p/P, u/Ctrl-R, dot, i/a/I/A/o/O; +- reserved application: ga, gi, bracket a/d, Ctrl-W h/j/k/l, colon, slash, Space leader; +- colon opens the root action palette; slash opens the current application's search action, using Transcript/task search as the Chat fallback. It is not a composer Vim text-search grammar in this milestone and never inserts slash in Normal; +- Enter sends the draft; Shift-Enter is unavailable in Normal/Visual and must not insert or send; +- Visual Enter is unavailable and consumed; +- popup/dialog bindings win over both groups. + +The effective keymap, not a second parser, implements this precedence. A shorter prefix and longer complete chord use GPUI's pending-input timeout and which-key display. + +### 17.13 Composer tests + +Pure-engine tests must cover: + +- ASCII, combining characters, emoji, punctuation, whitespace, empty and multiline text; +- preferred column across uneven lines; +- 0 versus count parsing, including 10j; +- gg/G with and without counts; +- motion inclusivity/exclusivity; +- cw special case, word motions across newlines, empty documents, and final-newline operations; +- 2d3w; +- dd/cc/yy at first/last lines and trailing-newline boundaries; +- iw/aw on words, punctuation, and whitespace; +- characterwise/linewise register and p/P; +- forward/reverse Visual selections; +- single-grapheme/reversed Unicode Visual selection and Visual paste register replacement; +- undo/redo transaction boundaries; +- dot for insert, append, open line, ciw, delete, paste, Visual d/c/p, insertion Backspace/replacement/IME commit, and counted repeat including one-unit 2.; +- no-op commands not affecting history or dot. + +GPUI integration tests must prove: + +- Standard TextInput behavior is unchanged. +- Only the composer opts in. +- Search, login, rename, settings, question, and MCP inputs keep ordinary editing while the Vim profile is active. +- Normal printable commands do not insert. +- Insert still uses EntityInputHandler and IME. +- Escape precedence is correct. +- Enter/Shift-Enter behavior matches the contract. +- Slash palette and prompt history still work. +- Live profile switching safely resets state. +- Mode badge and cursor follow state. +- One u undoes all of ciwhello Escape. +- Remapping a composer motion/operator changes the real command, disables the old binding, and updates Shortcut Settings/which-key. +- Composer Normal j moves the caret while Transcript j moves semantic selection. +- ga, gi, colon, slash, leader, bracket commands, and Ctrl-W route according to the table from composer Normal. +- Rapid diw, 10j, d2d, and pending Escape work with no intervening render/context refresh. +- Invalid editor grammar never inserts, sends, or reaches Chat; Escape/focus/profile changes cancel it, while editor grammar has no time timeout. +- Static GPUI chord timeout/cancellation and shorter-binding dispatch preserve/clear key provenance correctly. +- Composer Escape cannot reach Chat run-stop before the documented final application-root stage. +- Profile switching mid-Insert, delayed old-profile prefix dispatch, mouse-to-Normal, and external draft replacement follow the termination table. + +## 18. Python Code Mode + +### 18.1 Settings and default state + +Expose two controls backed by one normalized runtime authority tuple: + +~~~rust +pub enum PythonCodeMode { + Off, + DeveloperPreview, +} + +pub enum UiControllerAccess { + Off, + ReadOnly, + FullAccess, +} + +pub struct ProgrammabilityAuthority { + pub code_mode: PythonCodeMode, + pub controller: UiControllerAccess, +} +~~~ + +Defaults: + +- Python Code Mode: Off. +- Maple UI Controller: Off. + +The actual authority tuple is session-scoped in this Developer Preview and starts Off/Off on every process launch, account change, or sign-out. Persist the one-time disclosure acceptance and interpreter preference, not active Code Mode or controller authority. This fail-closed choice prevents a failed downgrade write or stale file from silently restoring Python/Full Access after a crash. A future decision to persist authority requires a separate reviewed revocation/startup design. + +Normalize every transition atomically: code_mode=Off implies controller=Off, and ReadOnly/FullAccess is invalid unless code_mode=DeveloperPreview. The controller selector is disabled while Code Mode is Off. Turning Code Mode Off cancels active Python executions, revokes controller leases, and stops all task kernels. + +### 18.2 Disclosure + +Enabling Developer Preview requires a one-time direct-user disclosure in the UI. + +Required language: + +> Developer Preview: Python runs with your macOS user permissions. Maple provides project/scratch paths, no supported network API, and best-effort Python guardrails, but this is not a hardened sandbox. Python code may access other files, processes, credentials available to your user, or the network. Enable it only for tasks you trust. + +The status UI should say: + +~~~text +Project files: intended read/write +Scratch: read/write +Network: no supported Maple API; native Python may still connect +Process isolation: separate worker; not an OS security boundary +~~~ + +Do not say merely Sandboxed or Network disabled. + +Python's own documentation explicitly warns that Python-level audit hooks are not a sandbox. That warning should inform both implementation and copy: https://docs.python.org/3/library/sys.html#sys.addaudithook + +### 18.3 Kernel identity + +Use: + +~~~rust +pub struct KernelKey { + pub account_scope: String, + pub task_id: String, +} +~~~ + +The task ID is the durable Goose session ID. + +Each kernel records: + +- canonical project root loaded from durable task metadata; +- private task scratch path; +- kernel generation; +- interpreter path/version/engine; +- active execution ID; +- last-used time; +- controller policy epoch. + +Before every execution, verify that: + +- the task still exists; +- it still belongs to the account; +- its canonical project root is valid and unchanged. + +If not, stop the kernel and return a structured error. Never fall back to the selected UI project, HOME, or another task's root. + +Every caller supplies only account/task identity. The backend reloads durable +task metadata and derives the canonical root rather than trusting a path copied +from whichever task the UI currently shows. Derive the scratch directory from +an account-scope digest plus a sanitized/hashed task ID, create it with +owner-only permissions, and validate the exact resolved target before deletion. + +### 18.4 Lifecycle + +- Lazy-create on the first Python call for a task. +- Persist namespace while the app runs, even when another task is selected. +- Serialize to one active execution per kernel. +- Return busy rather than building an unbounded queue. +- Interrupt attempts to stop the current execution while retaining state if safe. +- Reset clears the user namespace and pending async tasks in the same worker. +- Restart terminates the process group/job, reaps the direct worker, performs best-effort cleanup of tracked descendants, and launches a fresh interpreter. +- Hard timeout, protocol corruption, or failed interrupt kills and marks/restarts the worker. +- Task deletion first tombstones the kernel key, revokes admission/leases, stops and reaps the direct worker plus tracked descendants, and only then removes the exact validated scratch directory. +- Task archive retains the kernel. +- Logout kills/reaps all direct workers for that account and best-effort tracked descendants. +- App quit stops admission, cancels executions, terminates each process group/job, reaps each direct worker, and verifies tracked/cooperative descendants exit where observable. + +Cap active kernels, initially eight. Eviction must be explicit in result/UI because it loses in-memory state. Prefer least-recently-used idle eviction; never evict a running kernel. If all eight are running, a ninth request returns a structured capacity_exceeded immediately rather than waiting indefinitely. Clear Scratch is allowed only after its kernel is idle/stopped; otherwise stop first or return busy. + +Use actor ownership so service/global locks are not held while waiting on Python or GPUI. + +### 18.5 Interpreter resolution + +The Developer Preview may use an external native interpreter, but resolution must be deterministic and visible: + +1. Explicit `MAPLE_CODE_MODE_PYTHON` path. +2. The saved explicit interpreter preference. +3. A future bundled interpreter if present. +4. Resolved `python3` from the login/current PATH. +5. Common platform paths as a final developer fallback. + +Requirements: + +- Resolve an executable path, not an arbitrary shell command. +- Require Python 3.10 or newer and probe version/engine dependencies before accepting. +- Select IPython only when it imports successfully in the isolated probe; + otherwise use the CPython engine. +- Probe a changed preference before publishing it. On success, atomically swap + the preference and stop existing kernels; an invalid explicit path fails + closed instead of silently falling through. +- Show path, Python version, and IPython/CPython engine in Settings. +- Refuse an unusable interpreter with a clear error. +- The Nix dev shell provides a pinned nixpkgs Python plus IPython so validation + is reproducible and does not rely on Apple's system Python. + +Bundling Python in every release artifact and environment/package management are explicit follow-ups. + +### 18.6 Worker process boundary + +Python runs outside the GPUI process. + +Use private inherited pipes, not a loopback TCP server: + +- host-to-worker framed requests on a dedicated inherited descriptor; +- worker-to-host framed protocol on a second dedicated inherited descriptor; +- ordinary child stdout and stderr on separate capture pipes; +- user-code stdin replaced with a controlled EOF/error stream rather than either protocol descriptor. + +This keeps input(), sys.stdin.buffer.read(), os.read(0, ...), user print, native output, and subprocess output from stealing or corrupting protocol frames. If dedicated bidirectional descriptors are temporarily impossible on one platform, fail or use a clearly isolated equivalent; do not quietly place host messages on user stdin or mix arbitrary native stdout with JSON framing. + +After bootstrap handoff, mark protocol descriptors close-on-exec/non-inheritable so ordinary subprocesses do not receive them; preserve only stdout/stderr capture inheritance where required. The fixed worker retains the descriptors privately, while user code still runs in the same native process and therefore is not treated as adversarially isolated. + +The worker has a concurrent control architecture: + +- one dedicated protocol-reader thread continuously reads host frames during execution; +- Python/IPython user execution remains on the Python main thread; +- sdk_result delivery is posted thread-safely into the active asyncio loop/future; +- controller_changed, cancellation, and shutdown update atomic/thread-safe control state immediately; +- one serialized protocol writer owns every worker-to-host frame; +- a second execute request while one is active receives busy and is never run concurrently. + +Cooperative Python cancellation can be scheduled through the loop. When Python or native code prevents it, the Rust host performs the platform-specific interrupt/termination sequence; protocol-reader liveness alone is not mistaken for execution cancellation. + +Protocol: + +- fixed protocol version; +- four-byte big-endian frame length; +- UTF-8 JSON payload; +- maximum frame size; +- kernel generation; +- reject unknown, oversized, duplicate-terminal, stale-generation, and out-of-order messages. + +Use a common envelope with version, kind, kernel_generation, and a message sequence. Correlation fields are kind-specific rather than fake IDs on connection messages: + +- hello/ready carry a random handshake_nonce and generation, no program/execution; +- execute carries request_id, program_id, execution_id, and optional model_run_id; +- cancel carries request_id plus the exact program_id/execution_id; +- reset/restart/shutdown carry a control request_id and generation; +- controller_changed carries policy_epoch and generation; +- sdk_call/sdk_result carry sdk_request_id plus program_id/execution_id; +- display/completed/fatal carry program_id/execution_id when execution-scoped, otherwise a control request or fatal generation. + +Validate an explicit state machine: Spawning to Handshaking to Idle to Running(program, execution) to Interrupting to Idle/Dead. Only controller_changed, matching sdk_result, matching cancel, and shutdown are accepted concurrently with Running. Duplicate/out-of-order terminal messages, generation mismatches, an SDK result for a nonpending request, or execution output while Idle are protocol corruption and force restart. + +Host-to-worker: + +- hello; +- execute; +- reset; +- restart; +- cancel; +- shutdown; +- controller_changed; +- sdk_result. + +Worker-to-host: + +- ready; +- display; +- sdk_call; +- completed; +- fatal. + +Raw stdout and stderr capture pipes are the sole source of ordinary output events, including os.write, native libraries, and subprocesses. Attribute their bytes to the active kernel generation/program/execution. A host-side aggregator assigns one merge sequence to stdout, stderr, protocol display, and terminal arrivals as their respective pumps deliver them; cross-descriptor ordering is best-effort and must be labeled as such. At terminal completion the worker flushes Python streams and writes an execution-specific high-entropy barrier to each raw pipe before emitting completed. The host strips the barriers and does not surface terminal completion until both capture pumps have observed them or a bounded drain timeout forces restart. Output after a barrier indicates a lingering/background producer, is marked contaminated, and forces a restart rather than being attributed to a later cell. + +Use a fixed checked-in bootstrap script. Do not generate Python source ad hoc from Rust. + +The protocol channel is private by process construction, but native Python is not a security boundary. A hostile program can enumerate or corrupt inherited descriptors; treat that as protocol failure and restart, not as a sandbox escape that Maple claims to prevent. + +### 18.7 Execution engine + +Prefer IPython InteractiveShell.run_cell_async when the selected interpreter has IPython. Official API reference: https://ipython.readthedocs.io/en/stable/api/generated/IPython.core.interactiveshell.html + +Provide a CPython fallback: + +- one persistent globals namespace; +- compile with ast.PyCF_ALLOW_TOP_LEVEL_AWAIT; +- a persistent event-loop strategy; +- capture the final expression; +- retain imports and variables between calls. + +Example: + +~~~python +x = 41 +x + 1 +~~~ + +returns 42 and leaves x available for the next cell. + +Capture: + +- interleaved stdout/stderr events; +- final text representation; +- structured exception type, message, and traceback; +- bounded text/plain rich-display representation where feasible; +- engine/version; +- duration; +- truncation; +- whether forced restart occurred. + +At execution completion, cancel unowned pending async tasks. Also detect Python-managed threads and tracked/cooperative process-group descendants created by the cell. Code Mode does not promise persistent background work in this milestone: give observable work a short cooperative grace period, then mark the kernel contaminated and force Restart if it remains or writes after the output barrier. A native extension can create unenumerable threads or a subprocess can setsid/double-fork out of the group; cleanup is best-effort under the same non-sandbox Developer Preview disclosure. A soft interrupt may leave partially mutated Python globals; show that fact and offer/perform Restart according to the result. Persistent variables are useful; invisible background jobs are not. + +### 18.8 Bounds + +Initial defaults: + +| Resource | Limit | +|---|---:| +| Source per execution | 128 KiB | +| Protocol frame | 1 MiB | +| Captured output total | 256 KiB | +| Single result/display item | 64 KiB | +| Display item count | 16 | +| Default execution time | 60 seconds | +| SDK calls per execution | 256 | +| Interrupt grace | 500 to 750 ms | +| Active kernels | 8 | + +Make limits constants/config with tests. + +An output flood cancels the execution and terminates/restarts when necessary. It does not continue producing discarded data indefinitely. + +### 18.9 Cancellation and Stop + +Every execution has an always-present host-generated `program_id` and +`execution_id`. A model-triggered execution additionally has the owning +`model_run_id` and Goose `CancellationToken`. The protocol retains a UserCode +origin for trusted compatibility paths, but this preview does not mount a +human execution surface in chat. + +Stopping: + +1. Revoke the execution's UI capability lease. +2. Mark the execution cancelled so late sdk_call frames are rejected. +3. Request a cooperative loop/interpreter interrupt, then use the platform-appropriate hard-interrupt mechanism if needed. On Unix this may include a process-group signal; do not assume Windows has an equivalent SIGINT path. +4. Wait the short grace period. +5. Terminate the process group/job if it does not stop, reap the direct worker, and verify tracked/cooperative descendants exited where observable; report the native containment limit honestly. +6. Restart or mark the kernel stopped, and report what happened. + +Once cancelled, an execution ID can never invoke another action. A +model-triggered Stop revokes the execution lease and cancels the owning Goose +run as well, so that same run cannot call `python_code` again or invoke another +controller action. Track stopped model run IDs at the host boundary until the +run is terminal. + +While any Python/model UI program runs, or a failed teardown retains an exact +cleanup identity, its Stop/Retry Stop control remains visible in Settings > +Programmability. There is intentionally no global program HUD. + +Cancellation does not undo completed actions. Audit/result copy must say so. + +### 18.10 Intended filesystem/network posture + +Launch with: + +- cwd set to the task's canonical project root; +- HOME and temp paths pointed at private task scratch; +- Command::env_clear followed by a minimal explicit allowlist needed for locale, interpreter execution, project/scratch metadata, and platform runtime behavior; +- no intentionally passed Maple/OpenSecret auth tokens, credential paths, proxy variables, dynamic-loader injection variables, cloud credentials, or unrelated parent environment; +- no database handles; +- explicit MAPLE_PROJECT_ROOT and MAPLE_SCRATCH_DIR metadata. + +The ordinary intended capability is: + +- project root: read/write; +- task scratch: read/write; +- network: no supported Maple API and best-effort ordinary Python guardrails; native Python still has the macOS user's ability to connect; +- outside-project files and subprocesses: discouraged/guarded through ordinary Python APIs where practical, but still reachable by native Python under the user's OS permissions. + +The worker may install best-effort import/audit guards to prevent ordinary socket, subprocess, outside-write, or credential access. These are optional ergonomics and useful guardrails, not the authority boundary. Native modules, ctypes, direct syscalls, or reading the user's existing app/auth files may bypass them. "No Maple auth tokens" means Maple does not pass tokens into the child; it does not claim the native worker cannot discover files its macOS user can read. Tests and UI must reflect the honest Developer Preview boundary, and one failed socket call is never evidence of a sandbox. + +Production hardening is a separate sandboxed macOS helper/XPC design with no network entitlement and explicit file grants, plus equivalent Linux/Windows containment. + +## 19. User-visible Programmability controls + +The Developer Preview's human-facing surface is Settings-only. Python +execution remains model-driven through `python_code`. + +### 19.1 Entry point + +- Settings > Programmability is the only visible Code Mode/controller surface. +- `code_mode.open` remains a stable compatibility action and navigates there. +- Code Mode actions are excluded from command-palette discovery and the + default Standard/Vim keymaps. +- No chat strip, expandable human REPL, or global program HUD is mounted. + +### 19.2 Surface + +Settings > Programmability contains: + +- Developer Preview disclosure and explicit session-only Off-by-default copy; +- Python Code Mode Off/Developer Preview and Maple UI Controller + Off/Read Only/Full Access; +- interpreter preference plus resolved path, Python version, and + IPython/CPython engine; +- limits and current enforcement summary; +- active account/task kernels, source task, state, current or most recent + action, elapsed time, and generation; +- retained model-program and failed-teardown records; +- Stop/Retry Stop, Reset, Restart, and Clear Scratch lifecycle controls; +- bounded execution history and action audit with actor/transport labels. + +The surface discloses that all model `python_code` calls for the same +account/task share one process-local namespace. Variables, imported modules, +in-memory data, and secrets created by one call may be read or changed by a +later call. Reset/Restart visibly destroy that shared state and are audited. +Clear Scratch additionally follows the preconditions in Section 18.4. + +### 19.3 Authority + +Code Mode and controller-mode changes are direct-human-only. Settings reflects +the normalized runtime tuple and cannot restore active authority from disk. + +## 20. Model-facing python_code tool + +Add python_code to MapleDeveloperClient only when Developer Preview is currently enabled. + +Suggested discriminated input: + +~~~json +{ + "operation": "execute", + "code": "x = 41\nx + 1", + "timeout_seconds": 30 +} +~~~ + +Other operations: + +~~~json +{ "operation": "status" } +{ "operation": "reset" } +{ "operation": "restart" } +~~~ + +General Python works with the controller Off. + +Result includes: + +- kernel generation; +- program ID; +- execution ID; +- interpreter/engine; +- status; +- stdout; +- stderr; +- result; +- structured error; +- duration; +- truncation; +- cancelled/timed-out; +- forced restart. + +The tool is absent from list_tools when Code Mode is Off. A stale already-described invocation rechecks current policy and fails closed. + +execute, reset, and restart act on the source task's shared model-call kernel. +Sequential `python_code` calls share its namespace; reset/restart therefore +visibly destroy shared in-memory state. The tool description says so and +audit/history attributes the operation to the model. This is not protected by +UI Controller mode because general Python itself is allowed with the +controller Off. Clear Scratch is not a model tool operation in the first +milestone; it remains an explicit Settings action with the idle/stopped and +exact-target checks in Section 18.4. + +### 20.1 Runtime bridge + +Recommended ownership: + +~~~text +GPUI main thread + MapleApp semantic dispatcher + UiController request receiver + ^ + | bounded mpsc + oneshot + v +AgentBackend private Tokio runtime + CodeModeService + ^ + | framed process protocol + v +per-task Python worker +~~~ + +Do not route request/response controller calls through AgentServiceEvent, which is a cloneable one-way event stream. Use a separate bounded channel carrying: + +- request ID; +- host-assigned source task/run/execution; +- ActionCall or semantic query; +- oneshot response; +- cancellation/policy epoch. + +The GPUI root owns a foreground-executor receiver task/notifier that awaits channel readiness and explicitly wakes/schedules a cx.update on the GPUI event loop. Do not depend on an incidental render frame or input event. Each wake drains a bounded batch, then yields and reschedules itself if work remains so rendering/input stays responsive. Dropping MapleApp/window cancels the receiver and closes pending oneshots. + +Initial bridge contract: + +- bounded queue depth: 64 requests; +- UI drain batch: at most 16 requests per frame/tick, then yield for rendering/input; +- enqueue timeout: 250 ms, returning controller_busy/queue_full; +- ordinary response timeout: 30 seconds; events.wait has its own explicit maximum of 60 seconds plus a small broker drain margin; +- app unavailable/quitting, dropped receiver/oneshot, and closed window return structured ui_unavailable/cancelled errors; +- cancellation removes or tombstones queued work and is checked again on the UI thread; +- no kernel/service/global lock is held while enqueueing or awaiting a UI response. + +Async actions return accepted plus an operation ID and register an ActionExecutionHandle in a bounded operation table. That handle owns cancellation/backend task state and emits exactly one terminal event/result. Recheck policy immediately before submitting an irreversible backend effect. Before submission cancellation can prevent it; after submission it may complete and audit completed_after_cancel_request. + +A ProgramRecord owns the root Python execution plus every operation handle it started. When the cell returns accepted, the execution can become terminal and the kernel can accept a later cell, but the ProgramRecord, its visible Stop/audit state, policy lease, and bounded operation budget remain until all handles reach terminal status. A terminal Python execution cannot originate new SDK calls; its already-created handles may only finish/cancel under current policy. Multiple surviving ProgramRecords are shown separately or by a Stop All control and count against the global operation bound. + +Stop or controller downgrade after cell completion still cancels/revokes the surviving handles. Reset/Restart first Stop every nonterminal ProgramRecord for that task, wait the bounded grace, and then reset/replace the worker; already-submitted irreversible effects may still finish and must be audited. A new execution gets a new ProgramRecord and cannot borrow a prior record's lease or action budget. + +### 20.2 maple-agent seam + +MapleAgentHostResources should receive an optional trait object or broker handle defined below app, such as PythonCodeModeHost. MapleDeveloperClient uses it for python_code without depending on GPUI. + +The current developer-tool call context has a source session and CancellationToken but no stable owning model-run identity. Add a Maple-owned run-control seam rather than pretending the token is an ID: + +~~~rust +pub struct MapleModelRunContext { + pub run_id: ModelRunId, + pub task_id: String, + pub cancellation: CancellationToken, + pub control: Arc, +} + +pub trait ModelRunControl { + fn cancel_run(&self, run_id: &ModelRunId) -> Result<(), RunControlError>; + fn is_terminal(&self, run_id: &ModelRunId) -> bool; +} +~~~ + +Create/register the run ID where Maple starts a model generation, carry the context through the agent/developer-tool host resources into call_tool, and remove/tombstone it only when the run is terminal. Stop calls cancel_run and the token; terminal cleanup removes the registry entry. A tool invocation cannot mint or choose this ID. + +Forward the source session ID and run context unchanged as authoritative identity/cancellation context. Treat the supplied working directory only as a consistency hint: the Code Mode backend independently reloads durable project metadata and derives the canonical root for every human and model execution. + +## 21. maple_gpui SDK + +### 21.1 Availability + +The fixed maple_gpui package is installed in every Maple worker so code has one stable import contract. Rust enables its controller transport only when access is Read Only or Full Access. When controller is Off: + +- general Python still works; +- importing the same full `maple_gpui` SDK succeeds, while every host call + raises a structured `ControllerDisabled` error; +- the model-facing controller skill is not advertised. + +An old module reference after downgrade cannot retain power because Rust rechecks every call. + +### 21.2 Generic API + +Required initial API: + +~~~python +import maple_gpui as maple + +status = await maple.ui.status() +tasks = await maple.tasks.list(limit=100) +target = next((task for task in tasks if not task.active), None) +assert target is not None, "create a second harmless task first" + +catalog = await maple.actions.list( + query="task", + available_only=False, +) +descriptor = await maple.actions.describe("task.open") + +result = await maple.actions.invoke_and_wait( + "task.open", + {"task_id": target.id}, + precondition=target.precondition, + timeout=10, +) +~~~ + +Then add thin generated/domain conveniences: + +~~~python +tasks = await maple.tasks.list() +await maple.workspace.open_task(tasks[0].id) +await maple.transcript.focus_next(kind="annotation") +~~~ + +The generic discovery/invoke API is authoritative. Action listings are compact +and expose `needs_arguments`; use `actions.describe` to fetch full argument +schemas. Prefer `invoke_and_wait` when completion matters because it captures +the event cursor before invocation and cannot miss a fast completion. Plain +`invoke` plus `events.wait` remains available for workflows that deliberately +manage their own cursor. Convenience wrappers delegate to this API. + +### 21.3 SDK transport + +SDK calls are sdk_call frames on the worker's existing private protocol. + +Python sends: + +- method; +- JSON arguments; +- optional target/action precondition; +- its local request ID. + +It does not send a trusted actor, transport, controller mode, policy epoch, account, or capability token. + +Rust attaches: + +- account scope; +- source task; +- model run ID when applicable; +- host-generated program ID; +- kernel generation; +- execution ID; +- invocation actor (Model for model-triggered code; UserCode only for a trusted + compatibility path) and transport Python; +- current controller policy/epoch; +- cancellation. + +### 21.4 Required capabilities + +maple.ui: + +- describe; +- status; +- query; +- reveal; +- current_selection. + +maple.actions: + +- list; +- describe; +- availability; +- invoke. +- invoke_and_wait. + +maple.events: + +- wait. + +Thin namespaces: + +- tasks; +- projects; +- workspace; +- transcript; +- composer; +- settings; +- MCPs where descriptors exist. + +The SDK never receives: + +- GPUI Entity, Window, FocusHandle, App, Context, or callback; +- Rust pointers or arbitrary method names; +- OpenSecret credentials/tokens; +- SQLite connections; +- MapleAgentService or backend handles; +- raw secret settings. + +The Python worker's filesystem root remains its source task even if it navigates the visible Maple app to another task. + +Use tasks consistently as the public product/SDK namespace. Do not ship both tasks and sessions in the first API. A later sessions alias may be added only as a documented compatibility alias. + +### 21.5 Bounded discovery and pagination + +Every potentially large API accepts scope plus limit and opaque cursor where relevant: + +- ui.describe(scope="visible" | target, max_nodes, cursor); +- ui.query(..., limit, cursor); +- actions.list(..., limit, cursor); +- tasks.list(..., limit, cursor); +- transcript.list/focus helpers over a bounded semantic projection. + +Initial limits: + +| Surface | Initial bound | +|---|---:| +| Semantic nodes per page | 200 | +| Snapshot exposed text per page | 128 KiB | +| Action/query/task results per page | 100 | +| Controller response payload | 512 KiB | +| Semantic event ring | 2,048 events | +| Concurrent event waiters | 64 | +| Maximum event wait | 60 seconds | +| Action audit ring | 512 records | +| In-flight async operation table | 256 | + +Return next_cursor when truncated. If a page cannot fit the response cap, reduce it or return result_too_large; never exceed the 1 MiB protocol frame. Expired cursors/event history return resync_required and direct the caller to a fresh describe. Cursors include scope/revision/generation integrity so they cannot silently page through a different task or policy epoch. + +### 21.6 Discovery-first behavior + +The controller is semantic and introspectable. Models should: + +1. describe/query; +2. retain stable IDs and revision/event cursor; +3. inspect action availability; +4. invoke an action; +5. wait for a structured event if completion matters. + +They should not: + +- click coordinates; +- synthesize shortcut keys; +- sleep/poll when an event exists; +- guess stable IDs; +- cache authority; +- assume completed mutations are rolled back on Stop. + +## 22. Built-in maple-ui-controller skill + +Current Maple disables Goose's general built-in skills in TrustAwareSkillsClient. Do not turn all of them on. + +Add a small Maple-owned embedded skill catalog: + +- Advertise maple-ui-controller only when controller access is enabled. +- Intercept its load_skill request. +- Return an include_str-backed trusted SKILL.md. +- Optionally expose it through Maple's slash-command skill listing. +- Omit it entirely when controller is Off. + +TrustAwareSkillsClient merges only this Maple-owned descriptor/instruction into its existing catalog; do not enable Goose's other built-ins. Catalog preparation is not authority: intercepting a load_skill call rechecks current Code Mode, controller access, source task, and policy epoch. A skill advertised earlier but loaded after controller disablement returns ControllerDisabled. If access changes mid-turn, any already-loaded prose remains harmless because every SDK call is independently reauthorized. + +The skill teaches: + +- persistent per-task Python behavior; +- how to import maple_gpui; +- discovery-first workflow; +- stable IDs and action-specific preconditions; +- Read Only versus Full Access; +- events.wait rather than polling; +- structured errors and cancellation; +- no coordinate clicking or key synthesis; +- completed effects are not rolled back; +- small navigation/chaining examples. + +The skill grants no capability. It documents a capability Rust already enabled. + +## 23. Audit and visible run state + +### 23.1 Audit record + +Use a bounded in-memory ring initially: + +~~~rust +pub struct ActionAuditRecord { + pub sequence: u64, + pub invocation_id: InvocationId, + pub program_id: Option, + pub model_run_id: Option, + pub timestamp_ms: i64, + pub duration_ms: Option, + pub actor: InvocationActorSummary, + pub transport: InvocationTransport, + pub controller_access: UiControllerAccess, + pub policy_epoch: u64, + pub action_id: ActionId, + pub target: Option, + pub arguments: RedactedArguments, + pub effect: ActionEffect, + pub decision: PolicyDecision, + pub outcome: AuditOutcome, +} +~~~ + +Record: + +- accepted or denied decision; +- terminal completion/failure/cancel state; +- completed_after_cancel_request when an irreversible race actually occurred. + +Do not report async work as completed when it was merely spawned. + +### 23.2 Redaction + +Redaction is descriptor-driven and deny-by-default. + +Safe allowlisted fields may include: + +- stable task ID; +- action ID; +- boolean setter value; +- non-secret setting key; +- target kind. + +Never retain: + +- passwords; +- OAuth callbacks; +- tokens; +- secret headers; +- MCP secret environment values; +- raw account identifiers if a scoped digest suffices; +- arbitrary full Python output in the action audit. + +Python code/output already appears in its execution cell/tool timeline. Do not duplicate it into an indefinite action log. + +### 23.3 Visible state + +Settings > Programmability shows active and retained model/Python UI programs: + +- source task; +- Running/Stopping state; +- current or most recent action; +- elapsed time; +- exact program/execution identity where needed for cleanup; +- Stop or Retry Stop; +- bounded Code Mode history and action-audit details. + +There is no global HUD. `code_mode.open` provides a stable semantic route back +to the Settings surface. + +## 24. Cancellation model + +Every model/Python program has: + +- host-generated program ID; +- optional owning model run ID; +- execution ID; +- kernel generation; +- CancellationToken; +- current policy epoch; +- bounded action-call budget. + +Check cancellation: + +- before parsing/validation; +- before policy; +- before UI-thread dispatch; +- before any deferred external effect commits; +- before every later action in a chain; +- before resolving an event wait. + +Stopping a program: + +- revokes the controller lease immediately; +- prevents subsequent actions; +- cancels event waits; +- interrupts/terminates Python; +- attempts to cancel tracked backend work; +- reports effects that already completed. + +For model-triggered Python, Stop also cancels and tombstones the owning Goose +run at the developer-tool boundary, so that run cannot submit a fresh +`python_code` or another controller invocation. Controller calls carry both +actor and transport provenance throughout. + +No UI copy should imply transactional rollback. + +## 25. Settings persistence and fail-safe behavior + +Add serde-defaulted fields to AppSettings for: + +- keymap_profile; +- vim_leader; +- one-time Developer Preview disclosure acceptance if needed; +- optional interpreter path preference. + +Keep ProgrammabilityAuthority in runtime/account state, not AppSettings, for this Developer Preview. If older experimental fields exist, deserialize them tolerantly but normalize/ignore them to runtime Off/Off. + +Requirements: + +- Unknown persisted enum values fail only that field to the safer default through tolerant field-local deserialization; unrelated valid settings survive. serde(default) alone is not sufficient if an unknown enum would reject the entire document. +- Invalid keymap JSON leaves the last-known-good keymap active. +- Settings writer remains serialized/coalesced. +- An action result distinguishes local state applied from durable write failed when persistence matters. +- The first Off-to-DeveloperPreview transition completes and durably records the direct-user disclosure before enabling. If that write fails, remain Off. Once disclosure acceptance is durable, session authority transitions do not wait on settings persistence. +- Reductions revoke immediately and atomically normalize the runtime tuple. Because active authority is not persisted and every launch starts Off/Off, a crash/restart after any downgrade cannot resurrect old access. +- The existing serialized/coalesced settings writer must provide an awaited success/failure path for disclosure acceptance; fire-and-forget persistence is insufficient. +- Saved-auth startup may show local chat immediately while validation runs in + the background. Definitive rejection returns to Login and compare-clears only + the record that was loaded; timeout/network/server unavailability preserves + local history and credentials. Code Mode account synchronization waits for + that restore gate. +- Sign out closes Code Mode admission and confirms account-worker teardown + before clearing in-memory credentials and compare-clearing the captured + persisted record. A teardown failure retains the cleanup identity and + credentials needed to retry instead of reporting a false successful logout. + +## 26. Implementation slices and review map + +The exact diffs and commit boundaries may shift with integration work, but +preserve these architectural review boundaries. + +### Slice 1: Typed action core and semantic control routing + +Primary responsibilities: + +- maple-harness core types; +- registry and descriptor validation; +- policy matrix and Human Only; +- host-assigned actor/transport provenance; +- structured errors/results; +- audit ring; +- root dispatcher/GPUI bridge; +- initial stable semantic target types; +- migration of every current semantic control; +- checked-in control inventory and direct-callback audit; +- deterministic setters; +- tests for uniqueness, schemas, one-path execution, policy, generic-activation reauthorization, and redaction; +- architecture document in its then-current form. + +The slice cannot leave half of the buttons using direct semantic callbacks. + +### Slice 2: Keymap profiles and shortcut tooling + +Primary responsibilities: + +- Standard/Vim templates; +- keymap.json parser/resolver; +- complete atomic GPUI binding reload; +- null unbinding; +- context expansion; +- conflict/prefix reporting; +- Shortcuts settings page; +- shortcut recorder; +- command palette; +- which-key; +- settings persistence; +- tests for references, arguments, precedence, conflicts, recorder, reload, Standard regressions, application-chord/composer-token remapping, and ordinary inputs under the Vim profile. + +### Slice 3: Semantic application navigation + +Primary responsibilities: + +- semantic selection controller; +- stable transcript/sidebar/settings/menu/question/permission targets; +- reveal and reconciliation; +- stream-follow separation; +- application Vim commands and leader; +- ga, bracket a, bracket d, gi, Ctrl-W regions, colon, slash; +- stable semantic events/snapshots; +- tests for virtualized identities and streaming. + +### Slice 4: Composer Vim + +Primary responsibilities: + +- pure composer Vim engine; +- modes, motions, operators, counts, register, Visual, iw/aw; +- explicit edit transactions; +- structured dot repeat; +- TextInput integration; +- mode badge/cursor; +- application/composer transition; +- focused Unicode and regression tests. + +### Slice 5: Per-task Python Code Mode + +Primary responsibilities: + +- maple-code-mode crate; +- interpreter discovery; +- Nix Python/IPython development dependency; +- worker/bootstrap and framed protocol; +- persistent CPython/IPython execution; +- limits; +- process groups; +- Stop/Reset/Restart/cleanup; +- kernel manager keyed by account/task; +- Settings-only Code Mode status, lifecycle, and disclosure surface; +- model python_code tool for general computation; +- lifecycle/cancellation tests. + +### Slice 6: maple_gpui controller and end-to-end integration + +Primary responsibilities: + +- bounded GPUI request/oneshot bridge; +- SDK state/action/event APIs; +- controller Off/Read Only/Full Access; +- dynamic policy epoch/revocation; +- trusted built-in skill; +- audit/run status integration; +- model action chains; +- complete docs and final tests; +- any small live-validation fixes. + +Every slice should remain internally coherent and covered by focused tests. The +complete stack must be formatted, warning-clean for every supported feature +set, and pass the full repository test matrix. + +## 27. Required automated tests + +### 27.1 Action core + +- stable ID validation; +- unique descriptors; +- action arguments and result schemas serialize; +- every bindable descriptor has a GPUI adapter; +- every current semantic control reaches the same executor from pointer and typed action paths; +- Read Only allows observe/navigate and denies mutation/external; +- Full Access allows ordinary mutation/external; +- Human Only rejects Model/UserCode actors and Python/Macro/GeneratedUi transports even under Full Access; +- direct pointer/key/palette can invoke Human Only when available; +- programmatic GPUI dispatch cannot mint DirectUser; +- an actual multi-stroke key and a timeout-resolved shorter prefix consume valid window PendingKeyProvenance, while focus change/cancellation clears it and identical App::dispatch_action remains non-direct; +- internal follow-up calls inherit provenance and policy rather than becoming privileged; +- exhaustively test every actor/transport tuple: Human Only allows only DirectUser plus Pointer/Keybinding/CommandPalette, and every Internal tuple is denied; +- controller access changes cannot be controller-invoked; +- permission.respond is denied in Read Only and succeeds/audits in Full Access, including same-run responses as explicitly agreed; +- ui.activate_selected reauthorizes the concrete action; +- relevant target-precondition mismatch returns stale_target while unrelated global streaming revisions do not; +- stable ID to App::build_action to typed adapter reaches the semantic executor; +- one instrumented harmless action invoked through pointer, physical-key GPUI adapter, command palette, model/Python bridge, and generic activation reaches the identical executor identity plus policy/audit hooks exactly once per call; +- the checked-in control inventory has no unexplained semantic callback; +- sensitive fields never appear in audit JSON; +- cancellation prevents subsequent calls; +- async terminal status is recorded correctly. + +### 27.2 Keymaps + +- Standard and Vim templates never stack; +- every binding resolves to a registered stable action; +- every bound argument validates; +- null becomes an effective unbinding; +- user overrides beat template at equal context depth; +- exact, prefix, shadow, and possible conflicts are classified; +- invalid file preserves last-known-good; +- complete reload restores TextInput/default essentials; +- Vim-profile search/login/rename/settings/question/MCP inputs retain ordinary editing; +- profile switch safely resets modal state; +- recorder captures and cancels correctly; +- command palette uses current availability; +- which-key trie matches current context/prefix; +- Standard current shortcuts and type-to-compose behavior remain. +- user-remapped application chord and composer motion/operator replace the old binding and update which-key/settings. + +### 27.3 Semantic navigation + +- j/k/gg/G use stable IDs; +- off-screen reveal resolves through current virtual row index; +- a selected streaming item survives revision updates; +- insertion/reordering before selection does not move identity; +- removed selection chooses deterministic neighbor; +- leaving newest disables follow; +- streaming does not repin; +- G restores latest/follow; +- task switch restores valid per-task selection; +- popup contexts shadow application/composer; +- ga and bracket a traverse correct role/kind; +- bracket d returns unavailable with no annotations and works with fixtures; +- gi restores composer insertion; +- generic activation rechecks policy; +- hidden/internal/zero-height timeline records are absent from the navigable projection. + +### 27.4 Composer Vim + +All tests listed in Section 17.13 are required. + +### 27.5 Worker/core + +- fragmented and oversized frames; +- input()/raw stdin cannot consume control frames; +- handshake/version mismatch; +- complete envelope/state-machine validation for handshake, execute, sdk_call/result, cancel, controller change, reset/restart, shutdown, and terminal messages; +- protocol descriptors are non-inheritable by ordinary subprocesses before user code runs; +- duplicate/out-of-order/stale generation messages; +- CPython fallback; +- optional IPython selection; +- persistent variables/imports; +- top-level await; +- raw Python, os.write, native/subprocess stdout/stderr, display, final expression, exception, barrier ordering, and post-barrier contamination; +- sdk_result completes while the interpreter main thread awaits it; +- concurrent controller downgrade/cancel/shutdown reaches the protocol reader during execution; +- reset/restart state clearing; +- source/output/frame/time bounds; +- infinite loop cancellation; +- infinite output cancellation; +- worker crash/protocol corruption recovery; +- direct-worker reaping, process-group/job cleanup, and best-effort tracked-descendant cleanup; +- Python-managed thread/tracked-subprocess contamination forces restart, while native escape limitations are disclosed; +- per-task and per-account isolation; +- task root verification; +- eight-kernel cap/idle eviction and capacity_exceeded for a ninth request while all run; +- task deletion/Clear Scratch races and exact scratch target validation; +- env_clear/minimal allowlist with known credential, proxy, loader-injection, and auth variables absent; +- shutdown reaps every direct worker and reports any observable tracked-descendant cleanup failure. + +Use two test tiers: deterministic protocol/supervision tests with a fixture worker, and native CPython/IPython integration tests inside the Nix environment. If IPython is unavailable in a non-Nix CI job, report that optional engine test as explicitly skipped; do not silently count a fixture as native coverage. + +### 27.6 Agent/model integration + +- python_code absent when Off; +- general Python succeeds with controller Off; +- source task project root is used even when another UI task is selected; +- Goose cancellation reaches the worker; +- Maple-owned model run IDs are unforgeable, cancel_run reaches the owning generation, and terminal cleanup removes/tombstones the registry entry; +- model Stop cancels/tombstones the owning run so it cannot call python_code or controller actions again; +- logout/task deletion clean up kernels; +- stale tool calls recheck current policy; +- tool result is bounded; +- maple-ui-controller skill appears only when controller enabled; +- a skill advertised before disablement cannot be loaded afterward; +- sequential model executions demonstrably share the documented namespace, + including Reset/Restart behavior. + +### 27.7 SDK/controller + +- Read Only describe/query/navigation succeeds; +- Read Only mutation fails; +- Full Access ordinary mutation succeeds; +- permanent account/identity Human Only actions fail under Full Access; +- generic activation cannot bypass; +- schema and stale revision errors are structured; +- pagination, result_too_large, expired cursor/resync, and response caps work; +- bridge queue-full/enqueue-timeout/UI-closed/app-quitting/dropped-response paths are structured and release waiters; +- an SDK request wakes and completes against a totally idle GPUI loop with no render/input event; +- no service/kernel/global lock is held while awaiting GPUI; +- downgrade while a call is queued fails closed; +- no SDK call occurs after cancellation; +- describe redacts secure fields; +- events.wait is sequence-safe, bounded, timed, and cancellable; +- retained SDK object after downgrade has no authority; +- a cell returning accepted retains a visible/stoppable ProgramRecord until every handle terminates; Stop/downgrade/Reset/Restart and a later new execution obey the lifetime contract. + +### 27.8 Settings + +- defaults are Standard, Code Mode Off, controller Off; +- unknown values fail safe; +- disclosure is required; +- controller cannot enable while Code Mode Off; +- disabling immediately revokes active work; +- keymap parse failure is visible and non-destructive; +- settings write errors are surfaced; +- first disclosure write failure leaves runtime Off; +- every downgrade revokes immediately, and crash/restart/account change always returns runtime authority to Off/Off; +- a stale/unknown legacy Code Mode/controller field cannot activate authority or discard unrelated settings. + +## 28. Repository validation + +Run focused tests throughout. Before upstream review, run the repository's +complete single-host checks from the Nix environment. Release/live validation +is a separate gate and should be repeated in proportion to code changes; an +upstream rebase or documentation-only follow-up does not require rebuilding a +previously proven release artifact merely to request architectural feedback. + +The repository currently defines: + +~~~text +just fmt +just ci +just release +~~~ + +`just ci` is the authoritative single-host pre-commit command. It covers: + +- cargo fmt check; +- the strict semantic-control inventory; +- Clippy for the default, combined headless, ACP-only, and proxy-only feature + sets with warnings denied; +- workspace build/tests; +- headless tests. + +It does not reproduce GitHub's cross-OS matrix or separate Linux release job. +`just release` is also separate. + +Also run focused package/test commands that make failure diagnosis legible. + +Do not treat a zero-test filter as validation. + +### 28.1 Nix/macOS + +Use the repository flake and external Xcode/Metal development path. `just` is +a repository prerequisite and must already be available on PATH; the flake +currently supplies the pinned build/runtime dependencies but not `just` itself. + +~~~text +MAPLE_NIX_XCODE_VERSION= nix develop --no-update-lock-file --command just ci +MAPLE_NIX_XCODE_VERSION= nix develop --no-update-lock-file --command cargo build --release -p maple-gpui --locked +~~~ + +On macOS the shell honors `MAPLE_NIX_XCODE_VERSION` first, then a valid +inherited `DEVELOPER_DIR`, `/Applications/Xcode.app`, and finally the +installation selected by `xcode-select`. It derives `SDKROOT` with +`/usr/bin/xcrun` and selects that Xcode's compiler for native dependencies. +Verify the optional Metal toolchain as documented in README. Keep the pure Nix +build/runtime-shader path working where practical. + +Release builds use fat LTO and one codegen unit. They can be quiet and memory-heavy for a long time. Do not declare a hang while rustc/linker processes are active. Be kind to the big-memory LTO. + +Adding Python/IPython to the dev shell must preserve: + +- aarch64-darwin; +- aarch64-linux; +- x86_64-linux; +- pure package behavior where Python is not yet bundled into the app output. + +The macOS release build and GUI behavior are the primary gate. Keep non-desktop/headless feature sets compiling. + +## 29. Live macOS validation + +Automated tests are not sufficient for the implementation milestone. Launch +the exact newly built macOS app and exercise that app through direct or +automated UI interaction. + +Record: + +- git commit; +- binary path and hash; +- running process executable path; +- interpreter path/version; +- enabled profile/controller; +- visible screenshots or precise UI observations. + +Controller correctness must not depend solely on external model credentials. +First exercise the same broker/SDK path with an in-process or deterministic +agent/controller fixture, then perform the live model-driven exercises when a +configured model service is available. If it is unavailable, report that exact +limitation and the fixture evidence; do not silently waive or pretend the +model exercise ran. + +Do not grant macOS privacy/security permissions during validation without a new direct instruction. + +### 29.1 Settings and shortcuts + +1. Open Settings. +2. Verify Keyboard Shortcuts and Programmability sections. +3. Search actions by label, stable ID, and key. +4. Switch Standard to Vim and back. +5. Confirm Standard shortcuts remain ordinary. +6. Record a custom binding. +7. Create/inspect a conflict. +8. Disable with null. +9. Reset one action and the profile. +10. Verify unbound/conflict filters. +11. Open the command palette. +12. Trigger a leader sequence and see which-key. +13. Verify portable Secondary shortcuts resolve to Cmd on macOS. +14. Exercise native project and attachment pickers, including cancellation; + verify explicit controller paths do not open a picker. + +### 29.2 Application Vim + +1. Navigate task/project/sidebar rows with j/k/h/l. +2. Open a task with Enter. +3. Navigate transcript with j/k/gg/G. +4. Verify selected row highlight and off-screen reveal. +5. Use ga and bracket a. +6. Verify bracket d is honestly unavailable if no annotation objects exist. +7. Use Ctrl-W region movement. +8. Use colon palette and Space leader. +9. Start/observe streaming, navigate away, and verify it does not steal selection or repin. +10. Use G to return to newest/follow. +11. Use gi to return to the stored composer insertion, then replace the draft + externally and verify gi safely lands at the current draft end. + +### 29.3 Composer Vim + +Exercise, at minimum: + +- Insert/Normal/Visual transitions; +- h/j/k/l, w/b/e, 0/dollar, gg/G; +- i/a/I/A/o/O; +- dw, d$, dd; +- cw, ciw, cc; +- yw, yiw, yy; +- x, p/P; +- counts such as 3w, 2dd, 2d3w; +- u and Ctrl-R; +- Visual yank/delete/change; +- ciwhello Escape followed by dot on another word; +- A! Escape followed by dot; +- Standard profile typing after switching back; +- slash palette/history/send/Shift-Enter regressions. + +Accessibility automation may not perfectly emulate Vim timing. Supplement UI +automation with deterministic engine/integration tests and report which +behaviors were directly observed. + +### 29.4 General Python with controller Off + +Use sequential model `python_code` calls to: + +- assign a variable and use it later; +- print stdout and stderr; +- execute top-level await; +- raise and display an exception; +- Reset and verify state clears in a later call; +- Restart and verify generation changes in Settings; +- create/read a fixture in the task project; +- create/read scratch metadata; +- attempt outside-project read and a socket connection. + +For the native Developer Preview, any successful escape must match the warning and report, not contradict UI claims. +Verify execution history, interpreter state, and lifecycle controls only in +Settings > Programmability; no chat REPL or global Code Mode HUD should appear. + +### 29.5 Controller Read Only + +Ask the model through python_code to: + +- import maple_gpui; +- describe current state; +- list/search actions; +- list tasks; +- open/navigate a task or transcript item; +- wait for a semantic event; +- attempt a settings mutation and receive policy_denied; +- attempt sign out and receive policy_denied. + +Verify the UI visibly follows navigation where appropriate. + +### 29.6 Controller Full Access + +Exercise reversible ordinary actions: + +- open Settings/Shortcuts/Programmability; +- switch a view/section; +- set a reversible ordinary preference and restore it; +- open another task; +- send a harmless test message only in a task expressly used for validation; +- inspect/configure an MCP only if a reversible local test fixture exists; +- exercise permission.respond against an expressly created harmless validation prompt and verify it succeeds with conspicuous Full Access audit provenance; +- verify sign out and controller/Code Mode self-escalation remain denied from Python; +- as the final controller exercise only, verify app.quit returns/records accepted_terminal before orderly shutdown, then relaunch for the lifecycle checks if needed. + +Use discovery and action invocation, not coordinates/keys. + +### 29.7 Stop and lifecycle + +1. Start an infinite loop. +2. Open Settings > Programmability and press the matching Stop control. +3. Verify UI remains responsive. +4. Verify worker/process group disappears or restarts. +5. Run a later model `python_code` call successfully. +6. Start an output flood and repeat. +7. Use two tasks and verify separate namespaces/scratch/root. +8. Downgrade controller during a wait/pending call and verify revocation. +9. Sign out directly and verify all account kernels stop. +10. Quit and verify no worker process remains. +11. Relaunch and verify runtime Code Mode/controller authority is Off/Off even if it had been Full Access before quit. +12. Inspect audit entries for actor, transport, action, decision, result, and redaction. +13. Relaunch with saved credentials and verify optimistic local-chat restore, + background validation, and subsequent Code Mode account synchronization. + +## 30. Review and promotion gates + +Before proposing promotion beyond Developer Preview: + +- the branch and worktree are clean and based on current upstream; +- `just ci` passes, including the strict semantic-control inventory; +- release-mode compilation succeeds; +- the exact built process is launched and identified; +- Settings, Standard shortcuts, application/composer Vim, Python Code Mode, + and model-driven `maple_gpui` workflows are exercised live; +- platform-specific validation is identified separately from portable + compile/test evidence; +- deviations from this design are documented with their rationale; +- production-hardening TODOs remain explicit rather than being hidden behind + Developer Preview language. + +An upstream proposal should summarize the architectural context, review map, +exact checks, live behaviors, honest Python boundary, and non-goals. Opening a +review does not authorize a merge, release, signing, or installation. + +## 31. Acceptance checklist + +The experiment is complete only if all are true: + +- [ ] One action executor serves pointer, shortcuts, palette, Vim, model, and Python. +- [ ] Every current semantic control is registered or explicitly justified as local gesture mechanics. +- [ ] Actions have stable IDs, typed args/results, documentation, availability, effect, policy, and recoverability. +- [ ] Actor, transport, and controller authority are host-assigned. +- [ ] Read Only cannot mutate. +- [ ] Full Access can perform ordinary controller-callable mutations without per-action prompts. +- [ ] Permanent account/identity Human Only actions reject every non-direct actor/transport, including generic activation. +- [ ] Agent tool permission remains a separate setting, while Full Access permission.respond behavior matches the explicit product decision and is audited. +- [ ] Stable semantic selection survives virtualization and streaming. +- [ ] Standard and Vim are replacement templates with arbitrary overrides. +- [ ] Shortcut settings, recorder, conflicts, palette, and which-key work. +- [ ] Application Vim covers chat, transcript, sidebar, tasks/projects, settings, menus, questions, permissions, tools, and annotation-ready navigation proven by fixtures; a live app with no annotation producer honestly reports unavailable. +- [ ] Composer Vim includes every agreed command and structured dot repeat. +- [ ] Standard inputs remain unaffected. +- [ ] Python is persistent per account/task and outside GPUI. +- [ ] Python has bounded protocol/output/runtime/kernels and working Stop/Reset/Restart. +- [ ] General Python works with controller Off. +- [ ] maple_gpui discovery/action/event chaining works. +- [ ] Current policy is rechecked on every SDK action. +- [ ] Runtime Code Mode/controller authority is one normalized session tuple and relaunch/account change starts Off/Off. +- [ ] Built-in controller skill is present only when enabled. +- [ ] Security disclosure is honest. +- [ ] just ci and release build pass. +- [ ] Exact release app was launched and validated through UI. +- [ ] Upstream review material explains the context, architecture, validation, and remaining hardening without claiming production readiness. + +## 32. Future direction + +Once this vertical spine is proven, the most interesting next layers are: + +1. Saved named action programs/macros bindable like ordinary actions. +2. Transactional or parallel composition semantics. +3. Hardened native Code Mode helpers. +4. Durable Python environments and RLM orchestration. +5. Versioned declarative generated UI. +6. Capability-limited extensions. +7. Multi-window and remote-session semantic control. + +The core thesis should remain: + +> Maple is not a GUI with AI automation bolted onto it. Maple is a programmable, typed, semantic application whose human and model interfaces are clients of the same harness. + +## 33. Developer Preview implementation boundaries + +The Settings-only placement is incorporated throughout the normative sections +above. The implementation also records two proof-of-concept boundaries that +must remain explicit during review and production hardening. + +### 33.1 Developer Preview teardown receipt + +The proof-of-concept implementation closes Code Mode admission before an Off +transition, drains already-published admission reservations, and captures the +exact active program/execution identities from every responsive account-kernel +actor that is successfully observed before worker interruption begins. +Settings unions that atomic backend receipt with the last account-wide status +poll and retained `ActionHost` ProgramRecords. +This prevents a failed Off transition from losing a pure Python program or a +retained controller program merely because the 250 ms presentation poll had +not observed it yet. Stale account/policy completions cannot merge into or +retarget a newer teardown generation. + +One Developer Preview boundary remains for production hardening: the worker +service removes kernel handles and the actor exits after its bounded shutdown +attempt even when process cleanup returns diagnostics. `Retry Stop` therefore +revalidates the retained exact identity and clears a target that is already +terminal, but it is not guaranteed to issue a second operating-system cleanup +attempt against the same handle. A production implementation should retain a +separate retry-addressable cleanup owner until process-group termination is +positively confirmed. + +The proof-of-concept receipt capture is serial and has no per-actor timeout, +and the receipt is returned to one transition waiter. Production hardening +should capture actor status concurrently with explicit bounds and durably own +the receipt across waiter cancellation, task failure, or process interruption. + +### 33.2 GPUI pointer-origin limitation + +GPUI 0.2.2 exposes window-wide pointer capture, which the Developer Preview +uses so occluding popups cannot bypass the root provenance boundary. Its public +`PlatformInput` representation does not distinguish native hardware input from +an in-process synthetic dispatch, however. The preview therefore proves a +bounded, single-use GPUI pointer-dispatch capability, not operating-system +attestation of a physical device event. The controller/model/Python surfaces +have no API to synthesize GPUI input, and programmatic semantic action dispatch +still receives no token. Production hardening should add an origin-bearing GPUI +API or mint direct-user provenance below the public synthetic dispatch seam.