diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1945ec..32d7cd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] +env: + # GitHub Actions runner Node 20 -> Node 24 deprecation (effective 2026-06-02). + # Forces JS-based actions onto Node 24 ahead of the cutover. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: check: name: cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 540e696..7001348 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: permissions: contents: write +env: + # GitHub Actions runner Node 20 -> Node 24 deprecation (effective 2026-06-02). + # Forces JS-based actions onto Node 24 ahead of the cutover. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: source-tarball: name: source tarball diff --git a/CHANGELOG.md b/CHANGELOG.md index fd894ab..b56bbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ This file tracks notable changes to the workspace tag stream source of truth for individual crate bumps. Tags map roughly to "workspace cuts" — a tag may bump multiple crates at once. +## Unreleased + +### Added + +`animus-config-protocol` 0.1.2: optional, typed +`AgentProfile.application_chat_controls` policy with bounded control enums and +configured references. Profile omission remains backward-compatible; overlay +omission inherits, explicit `null` revokes, and a concrete object replaces. +Unknown fields, duplicate or oversized lists, and invalid configured references +fail during canonical deserialization. + +`animus-plugin-protocol`: additive +`conversation_operation_fenced_append_v1` capability and +`ConversationAppendMessageRequest.operation_fence`. Shared backends validate +the exact operation lease and active conversation reservation atomically with +assistant-message insertion, preventing a provider result from a reclaimed or +terminalized runtime from becoming canonical. + +`animus-subject-protocol` 0.2.0 adds a non-downgradable authenticated subject +surface: + +- Required `SubjectRequestContext` carrying a typed `Actor` plus optional + request, correlation, and idempotency identifiers. +- Typed v2 list/get/create/update/status/delete request shapes and distinct + `subject/v2/` / `/v2/` method names. +- `ActorScopedSubjectBackend`, separate from the legacy global + `SubjectBackend`, so a v2 request can never silently fall through to v1. +- JSON Schema exports and compatibility tests for the new wire types. + +`animus-plugin-protocol`: shared multi-host chat operation authority via the +additive `conversation/operation_*` reserve, load, renew, execution-bind, +release, user-accept, and terminalize RPCs. The authenticated tenant/actor plus +repository, conversation, and caller key form the durable partition. Opaque +leases fence every mutation by token and expiry; reclaims rotate authority, +terminal receipts are immutable, and replay/load never expose lease tokens. + +`animus-plugin-protocol`: `ConversationScope.tenant_id`, an optional-on-wire, +1..=128-character opaque server-selected workspace/tenant partition key carried +by every conversation-store request. Shared backends include it in every +conversation/message key and validate it against the authenticated transport +actor, failing closed unless an operator explicitly pins a legacy tenant. +Conversation creation stamps owner from that authenticated call context, and +ordinary metadata saves cannot transfer or clear ownership. + +`animus-plugin-protocol`: optional +`ConversationMeta.active_operation_id` on the canonical load/save-meta path. +The field durably identifies which keyed chat operation owns a revision +reservation so that operation can recover after a crash. It is absent by +default for backward compatibility, constrained to 1..=128 ASCII alphanumeric +or `._:-` characters, writable only through `conversation/save_meta`, and +intentionally omitted from create requests and list summaries. + +`animus-plugin-protocol` (0.1.18): `PluginManifest.supports_mcp: Option` +— a first-class, plugin-DECLARED capability field the kernel reads instead of +hardcoding per-tool MCP behavior in a name table (REQUIREMENT-039 / TASK-277). + +- `PROTOCOL_VERSION` bumped `1.1.0` -> `1.2.0` (backward-compatible minor: the + field is optional and `#[serde(default, skip_serializing_if = "Option::is_none")]`). +- Back-compat: absent = undeclared; the kernel keeps its historical default + (provider plugins are MCP-capable). Only an explicit `false` opts a provider out. +- `animus-plugin-runtime` (0.2.2): `Plugin::supports_mcp(bool)` builder so a Rust + plugin author can declare the flag. The provider runtime (`provider_main`) emits + `None` for now — auto-mapping from `ProviderCapabilities.mcp` is deferred because + that flag defaults `false` and would regress providers relying on the default. +- This is the proof-of-pattern field for the wider REQUIREMENT-039 cleanup + (launch template, permission-mode flag, reasoning-effort, default model, ...). + ## v0.1.21 — config_source write-back (`config/write`) ### Added diff --git a/Cargo.toml b/Cargo.toml index c8fa4b3..84dbb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,8 +16,10 @@ members = [ "animus-durable-store-protocol", "animus-memory-store-protocol", "animus-notifier-protocol", + "animus-environment-protocol", "protocol", "animus-config-protocol", + "animus-application-protocol", ] resolver = "2" diff --git a/README.md b/README.md index 597fb85..143c10e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ | Crate | Standalone-compilable? | Notes | |---|---|---| | `animus-plugin-protocol` | yes | Wire types only; no external Animus deps. | +| `animus-application-protocol` | yes | Policy-neutral application wire types, generated JSON Schemas, and cross-language scalar limits. | | `animus-subject-protocol` | yes | Pure trait + schema definitions. | | `animus-provider-protocol` | yes | Pure trait + schema definitions. | | `animus-trigger-protocol` | yes | Pure trait + schema definitions for push-driven event sources (Slack, webhooks, file watchers, cron). | @@ -24,6 +25,7 @@ The protocol + subject + provider + runtime crates are usable today via git path | Crate | Purpose | |---|---| | [`animus-plugin-protocol`](./animus-plugin-protocol) | Wire types every plugin uses: `RpcRequest`, `RpcResponse`, `RpcNotification`, `RpcError`, error codes, `InitializeParams` / `InitializeResult`, `PluginManifest`, `HealthCheckResult`. | +| [`animus-application-protocol`](./animus-application-protocol) | Application-facing action/resource vocabularies, closed chat controls, durable chat receipts, and machine-readable UTF-8/numeric limits. Portals still own policy; spatial clients still own world state. | | [`animus-subject-protocol`](./animus-subject-protocol) | `SubjectBackend` trait + normalized `Subject` schema for backends like Linear, Jira, GitHub Issues, Notion, Asana — anything with a system-of-record API. | | [`animus-provider-protocol`](./animus-provider-protocol) | `ProviderBackend` trait + `AgentRunRequest`/`AgentRunResponse` shapes for LLM provider plugins (Claude, Codex, Gemini, OpenAI-compatible, on-prem). | | [`animus-trigger-protocol`](./animus-trigger-protocol) | `TriggerBackend` trait + `TriggerEvent`/`TriggerSchema` shapes for push-driven event sources (Slack mentions, generic webhooks, file watchers, cron). | @@ -32,6 +34,12 @@ The protocol + subject + provider + runtime crates are usable today via git path `animus-plugin-protocol` is the only required dependency for non-Rust plugin authors — and even then only as a reference. Any process that emits the documented JSON over stdio is a compatible Animus plugin. +Application consumers regenerate the committed Draft 2020-12 artifacts with +`cargo run -p animus-application-protocol --bin animus-application-protocol-export-schema`. +The generated `_limits.json` is normative alongside JSON Schema: JSON Schema +`maxLength` counts Unicode characters, while Animus transport ceilings count +encoded UTF-8 bytes. Downstream validators must enforce both. + ## Subject backend quickstart (Rust) Cargo.toml: diff --git a/animus-application-protocol/Cargo.toml b/animus-application-protocol/Cargo.toml new file mode 100644 index 0000000..8fbc207 --- /dev/null +++ b/animus-application-protocol/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "animus-application-protocol" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Launchapp.dev"] +homepage = "https://github.com/launchapp-dev/animus-cli" +repository = "https://github.com/launchapp-dev/animus-protocol" +description = "Application-facing wire contracts shared by Animus runtimes, portals, and spatial clients." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1.0" + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "animus-application-protocol-export-schema" +path = "src/bin/export_schema.rs" diff --git a/animus-application-protocol/src/bin/export_schema.rs b/animus-application-protocol/src/bin/export_schema.rs new file mode 100644 index 0000000..de3e610 --- /dev/null +++ b/animus-application-protocol/src/bin/export_schema.rs @@ -0,0 +1,149 @@ +//! Export JSON Schema artifacts for application-facing Animus wire types. + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use animus_application_protocol::{ + AllowedAction, AllowedApplicationChatControls, ApplicationChatControls, + ApplicationChatReceiptFrame, ApplicationChatTurnStatus, ApplicationResourceKind, + ResourceVisibility, APPLICATION_CHAT_CONTROLS_SCHEMA, MAX_APPLICATION_CHAT_CONTROLS_BYTES, + MAX_APPLICATION_CHAT_CONTROL_REF_BYTES, MAX_APPLICATION_CHAT_ERROR_BYTES, + MAX_APPLICATION_CHAT_SEQUENCE, MAX_APPLICATION_PROTOCOL_STRING_BYTES, +}; +use schemars::{schema_for, Schema}; + +fn default_out_dir() -> PathBuf { + let base = env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .and_then(|dir| dir.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + base.join("schemas").join("animus-application-protocol") +} + +fn parse_out_dir(args: &[String]) -> Option { + let mut iter = args.iter().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--out" | "-o" => return iter.next().map(PathBuf::from), + other if other.starts_with("--out=") => { + return Some(PathBuf::from(&other["--out=".len()..])); + } + _ => {} + } + } + None +} + +/// Build all public application schema artifacts. +pub fn all_schemas() -> Vec<(&'static str, Schema)> { + vec![ + ("AllowedAction", schema_for!(AllowedAction)), + ( + "ApplicationResourceKind", + schema_for!(ApplicationResourceKind), + ), + ("ResourceVisibility", schema_for!(ResourceVisibility)), + ( + "ApplicationChatControls", + schema_for!(ApplicationChatControls), + ), + ( + "AllowedApplicationChatControls", + schema_for!(AllowedApplicationChatControls), + ), + ( + "ApplicationChatReceiptFrame", + schema_for!(ApplicationChatReceiptFrame), + ), + ( + "ApplicationChatTurnStatus", + schema_for!(ApplicationChatTurnStatus), + ), + ] +} + +/// Write one file per type and a combined `_all.json` bundle. +pub fn export_to(out_dir: &Path) -> std::io::Result { + fs::create_dir_all(out_dir)?; + let schemas = all_schemas(); + let mut defs: BTreeMap = BTreeMap::new(); + for (name, schema) in &schemas { + let pretty = serde_json::to_string_pretty(schema).expect("schema serializes"); + fs::write(out_dir.join(format!("{name}.json")), format!("{pretty}\n"))?; + let mut value = serde_json::to_value(schema).expect("schema serializes"); + if let Some(object) = value.as_object_mut() { + if let Some(serde_json::Value::Object(inner)) = object.remove("$defs") { + for (key, definition) in inner { + defs.entry(key).or_insert(definition); + } + } + object.remove("$schema"); + } + defs.insert((*name).to_string(), value); + } + let limits = serde_json::json!({ + "schema": "animus.application.limits.v1", + "application_chat_controls_schema": APPLICATION_CHAT_CONTROLS_SCHEMA, + "application_chat_controls_max_utf8_bytes": MAX_APPLICATION_CHAT_CONTROLS_BYTES, + "application_chat_control_ref_max_utf8_bytes": MAX_APPLICATION_CHAT_CONTROL_REF_BYTES, + "application_protocol_string_max_utf8_bytes": MAX_APPLICATION_PROTOCOL_STRING_BYTES, + "application_chat_error_max_utf8_bytes": MAX_APPLICATION_CHAT_ERROR_BYTES, + "application_chat_sequence_max": MAX_APPLICATION_CHAT_SEQUENCE, + }); + let limits_pretty = serde_json::to_string_pretty(&limits).expect("limits serialize"); + fs::write(out_dir.join("_limits.json"), format!("{limits_pretty}\n"))?; + let bundle = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "animus-application-protocol", + "x-animus-limits": limits, + "$defs": defs, + }); + let pretty = serde_json::to_string_pretty(&bundle).expect("bundle serializes"); + fs::write(out_dir.join("_all.json"), format!("{pretty}\n"))?; + Ok(schemas.len()) +} + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + let out_dir = parse_out_dir(&args).unwrap_or_else(default_out_dir); + match export_to(&out_dir) { + Ok(count) => { + println!("wrote {count} schemas + _all.json to {}", out_dir.display()); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("export-schema: {error}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_is_complete() { + let tmp = tempfile::tempdir().unwrap(); + let count = export_to(tmp.path()).unwrap(); + assert_eq!(count, all_schemas().len()); + let bundle: serde_json::Value = + serde_json::from_str(&fs::read_to_string(tmp.path().join("_all.json")).unwrap()) + .unwrap(); + for (name, _) in all_schemas() { + assert!(bundle["$defs"].get(name).is_some(), "missing {name}"); + } + let limits: serde_json::Value = + serde_json::from_str(&fs::read_to_string(tmp.path().join("_limits.json")).unwrap()) + .unwrap(); + assert_eq!(limits["application_protocol_string_max_utf8_bytes"], 512); + assert_eq!( + limits["application_chat_sequence_max"], + 9_007_199_254_740_991_u64 + ); + assert_eq!(bundle["x-animus-limits"], limits); + } +} diff --git a/animus-application-protocol/src/lib.rs b/animus-application-protocol/src/lib.rs new file mode 100644 index 0000000..825adbd --- /dev/null +++ b/animus-application-protocol/src/lib.rs @@ -0,0 +1,541 @@ +//! Shared, application-facing Animus wire contracts. +//! +//! This crate owns only the language-neutral values that must agree across the +//! Animus runtime and application clients. A portal remains responsible for +//! authentication, authorization, projection, and deriving `allowed_actions`; +//! a spatial client remains responsible for its world protocol. Neither policy +//! nor world state belongs in this crate. + +#![warn(missing_docs)] + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Schema identifier for canonical application chat controls. +pub const APPLICATION_CHAT_CONTROLS_SCHEMA: &str = "animus.chat.application_controls.v1"; +/// Maximum encoded JSON size accepted for a controls envelope. +pub const MAX_APPLICATION_CHAT_CONTROLS_BYTES: usize = 2_048; +/// Maximum byte length of a configured profile or skill reference. +pub const MAX_APPLICATION_CHAT_CONTROL_REF_BYTES: usize = 64; +/// Maximum UTF-8 byte length of an application receipt identifier. +pub const MAX_APPLICATION_PROTOCOL_STRING_BYTES: usize = 512; +/// Maximum UTF-8 byte length of a safe application receipt error message. +pub const MAX_APPLICATION_CHAT_ERROR_BYTES: usize = 1_024; +/// Largest exact integer representable by every supported JSON consumer. +pub const MAX_APPLICATION_CHAT_SEQUENCE: u64 = 9_007_199_254_740_991; + +/// Portal-derived action a client may render for a projected resource. +/// +/// This enum standardizes the wire vocabulary; it does not grant an action. +/// The authenticated application boundary remains the authority that derives +/// the array for each caller and resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AllowedAction { + /// Read the resource projection. + Read, + /// Inspect an agent's safe configuration projection. + Inspect, + /// Launch a workflow run from the resource. + Launch, + /// Mint or consume a stream grant for a run. + Stream, + /// Send a message to a conversation. + Send, + /// Respond to an interaction. + Respond, +} + +/// Resource kinds exposed by the application projection boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ApplicationResourceKind { + /// Configured agent profile. + Agent, + /// Durable chat conversation. + Chat, + /// Workflow definition. + Workflow, + /// Workflow execution. + Run, + /// Backend-owned subject. + Subject, + /// Queue entry. + QueueEntry, + /// Durable operation record. + Operation, + /// Human interaction request. + Interaction, +} + +/// Visibility vocabulary carried by application resource projections. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ResourceVisibility { + /// Visible only through an explicit relationship or administration. + Private, + /// Visible within the authenticated organization boundary. + Org, + /// Publicly readable. + Public, +} + +/// Stable schema discriminator for [`ApplicationChatControls`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum ApplicationChatControlsSchema { + /// `animus.chat.application_controls.v1`. + #[default] + #[serde(rename = "animus.chat.application_controls.v1")] + V1, +} + +/// Application-safe reasoning effort requested from an authorized profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationReasoningEffort { + /// Low reasoning effort. + Low, + /// Medium reasoning effort. + Medium, + /// High reasoning effort. + High, +} + +/// Provider-neutral permission intent selected by an application. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationPermissionIntent { + /// Inherit safe provider defaults. + Default, + /// Review without broad edit permission. + Review, + /// Permit ordinary automated edits. + AutoEdit, + /// Request the profile's explicitly authorized unrestricted mode. + Unrestricted, +} + +impl ApplicationReasoningEffort { + /// Return the canonical wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + } + } +} + +impl ApplicationPermissionIntent { + /// Return the canonical wire value. + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Review => "review", + Self::AutoEdit => "auto_edit", + Self::Unrestricted => "unrestricted", + } + } +} + +fn deserialize_non_null_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer)? + .map(Some) + .ok_or_else(|| serde::de::Error::custom("explicit null is not allowed; omit the field")) +} + +/// A bounded reference to a server-configured agent profile or skill. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ApplicationConfiguredRef( + #[schemars( + length(min = 1, max = 64), + regex(pattern = r"^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._-]*$") + )] + String, +); + +impl ApplicationConfiguredRef { + /// Validate and construct a configured reference. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_application_configured_ref(&value)?; + Ok(Self(value)) + } + + /// Return the canonical wire value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ApplicationConfiguredRef { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// Validate the exact configured-reference grammar shared by all consumers. +pub fn validate_application_configured_ref(value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > MAX_APPLICATION_CHAT_CONTROL_REF_BYTES { + return Err(format!( + "configured reference must contain 1..={MAX_APPLICATION_CHAT_CONTROL_REF_BYTES} bytes" + )); + } + if !bytes[0].is_ascii_alphanumeric() + || !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || value.contains("..") + { + return Err("configured reference must start with an ASCII letter or digit, contain only ASCII letters, digits, '.', '_', or '-', and must not contain '..'".to_string()); + } + Ok(()) +} + +/// Closed application controls envelope accepted by `animus chat send`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ApplicationChatControls { + /// Exact controls schema discriminator. + pub schema: ApplicationChatControlsSchema, + /// Request kernel-mediated approvals when authorized by the profile. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null_option" + )] + #[schemars(with = "bool")] + pub approvals: Option, + /// Requested reasoning effort. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null_option" + )] + #[schemars(with = "ApplicationReasoningEffort")] + pub reasoning_effort: Option, + /// Requested provider-neutral permission intent. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null_option" + )] + #[schemars(with = "ApplicationPermissionIntent")] + pub permission_intent: Option, + /// Assertion of the conversation's canonical configured profile. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null_option" + )] + #[schemars(with = "ApplicationConfiguredRef")] + pub profile_ref: Option, + /// Configured skill selected from the profile's authorized options. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null_option" + )] + #[schemars(with = "ApplicationConfiguredRef")] + pub skill_ref: Option, +} + +/// Allowed application control values projected by an authenticated portal. +/// +/// This is a transport shape only. The portal decides which values appear. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AllowedApplicationChatControls { + /// Exact controls schema discriminator. + pub schema: ApplicationChatControlsSchema, + /// Allowed approval selections. + #[serde(default)] + pub approvals: Vec, + /// Allowed reasoning effort selections. + #[serde(default)] + pub reasoning_effort: Vec, + /// Allowed permission intent selections. + #[serde(default)] + pub permission_intent: Vec, + /// Allowed canonical profile assertion. + #[serde(default)] + pub profile_ref: Vec, + /// Allowed configured skill references. + #[serde(default)] + pub skill_ref: Vec, +} + +/// A bounded, non-empty receipt identifier with no ASCII control characters. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ApplicationProtocolString( + #[schemars( + length(min = 1, max = 512), + regex(pattern = r"^[^\u0000-\u001F\u007F]+$") + )] + String, +); + +impl ApplicationProtocolString { + /// Validate and construct a protocol string. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_APPLICATION_PROTOCOL_STRING_BYTES + || value.chars().any(|character| character.is_ascii_control()) + { + return Err(format!( + "application protocol string must contain 1..={MAX_APPLICATION_PROTOCOL_STRING_BYTES} UTF-8 bytes and no ASCII controls" + )); + } + Ok(Self(value)) + } + + /// Return the canonical wire value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ApplicationProtocolString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// Safe bounded diagnostic for a confirmed assistant failure receipt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ApplicationChatErrorMessage(#[schemars(length(min = 1, max = 1024))] String); + +impl ApplicationChatErrorMessage { + /// Validate and construct a safe error message. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > MAX_APPLICATION_CHAT_ERROR_BYTES { + return Err(format!( + "application chat error must contain 1..={MAX_APPLICATION_CHAT_ERROR_BYTES} UTF-8 bytes" + )); + } + Ok(Self(value)) + } + + /// Return the canonical wire value. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ApplicationChatErrorMessage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// Terminal status carried by an application chat receipt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationChatTurnStatus { + /// Assistant message persisted successfully. + Completed, + /// User message persisted but the provider failed. + AssistantFailed, + /// User message persisted but the provider was interrupted. + AssistantInterrupted, +} + +/// A non-negative chat sequence exactly representable by JavaScript clients. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema)] +#[serde(transparent)] +#[schemars(transparent)] +pub struct ApplicationChatSequence(#[schemars(range(max = 9_007_199_254_740_991_u64))] u64); + +impl ApplicationChatSequence { + /// Validate and construct a cross-language-safe sequence. + pub fn new(value: u64) -> Result { + if value > MAX_APPLICATION_CHAT_SEQUENCE { + return Err(format!( + "application chat sequence must be <= {MAX_APPLICATION_CHAT_SEQUENCE}" + )); + } + Ok(Self(value)) + } + + /// Return the canonical numeric value. + pub fn get(self) -> u64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for ApplicationChatSequence { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(u64::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } +} + +/// Exact durable JSONL receipt frames used by application chat callers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ApplicationChatReceiptFrame { + /// Canonical user row was durably accepted. + UserMessageAccepted { + /// Must be `user_accepted`. + status: UserAcceptedStatus, + /// Conversation identity. + conversation_id: ApplicationProtocolString, + /// Canonical user-message sequence. + seq: ApplicationChatSequence, + /// Canonical user-message identity. + message_id: ApplicationProtocolString, + /// Durable operation identity. + operation_id: ApplicationProtocolString, + }, + /// Assistant row was durably completed. + TurnCompleted { + /// Must be `completed`. + status: CompletedStatus, + /// Conversation identity. + conversation_id: ApplicationProtocolString, + /// Canonical assistant-message sequence. + seq: ApplicationChatSequence, + /// Canonical assistant-message identity. + message_id: ApplicationProtocolString, + /// Accepted user-message sequence. + user_seq: ApplicationChatSequence, + /// Accepted user-message identity. + user_message_id: ApplicationProtocolString, + /// Durable operation identity. + operation_id: ApplicationProtocolString, + /// Optional provider continuity pointer. + session_id: Option, + }, + /// User row is durable but assistant execution did not complete. + TurnFailed { + /// Confirmed partial-success status. + status: ApplicationChatFailureStatus, + /// Conversation identity. + conversation_id: ApplicationProtocolString, + /// Accepted user-message sequence. + user_seq: ApplicationChatSequence, + /// Accepted user-message identity. + user_message_id: ApplicationProtocolString, + /// Durable operation identity. + operation_id: ApplicationProtocolString, + /// Stable, non-sensitive failure code. + error_code: ApplicationProtocolString, + /// Safe bounded failure description. + error_message: ApplicationChatErrorMessage, + }, +} + +/// Literal accepted-user status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum UserAcceptedStatus { + /// `user_accepted`. + #[serde(rename = "user_accepted")] + UserAccepted, +} + +/// Literal completed-turn status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum CompletedStatus { + /// `completed`. + #[serde(rename = "completed")] + Completed, +} + +/// Confirmed partial-success terminal status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationChatFailureStatus { + /// Provider or assistant execution failed. + AssistantFailed, + /// Provider or assistant execution was interrupted. + AssistantInterrupted, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn controls_are_closed_and_refs_are_exact() { + let controls: ApplicationChatControls = serde_json::from_value(json!({ + "schema": APPLICATION_CHAT_CONTROLS_SCHEMA, + "approvals": true, + "reasoning_effort": "high", + "permission_intent": "review", + "profile_ref": "research.agent-1", + "skill_ref": "code_review" + })) + .unwrap(); + assert_eq!(controls.profile_ref.unwrap().as_str(), "research.agent-1"); + + for rejected in [ + json!({"schema": APPLICATION_CHAT_CONTROLS_SCHEMA, "argv": ["--danger"]}), + json!({"schema": "animus.chat.application_controls.v2"}), + json!({"schema": APPLICATION_CHAT_CONTROLS_SCHEMA, "profile_ref": "../secret"}), + json!({"schema": APPLICATION_CHAT_CONTROLS_SCHEMA, "approvals": null}), + ] { + assert!(serde_json::from_value::(rejected).is_err()); + } + } + + #[test] + fn receipt_frames_are_closed_and_utf8_byte_bounded() { + let accepted = json!({ + "type": "user_message_accepted", + "status": "user_accepted", + "conversation_id": "chat-1", + "seq": 6, + "message_id": "message-user", + "operation_id": "operation-1" + }); + assert!(serde_json::from_value::(accepted.clone()).is_ok()); + let mut extra = accepted; + extra["agent_id"] = json!("caller-controlled"); + assert!(serde_json::from_value::(extra).is_err()); + assert!(ApplicationProtocolString::new("é".repeat(256)).is_ok()); + assert!(ApplicationProtocolString::new(format!("{}x", "é".repeat(256))).is_err()); + assert!(ApplicationChatErrorMessage::new("é".repeat(512)).is_ok()); + assert!(ApplicationChatErrorMessage::new(format!("{}x", "é".repeat(512))).is_err()); + assert!(ApplicationChatSequence::new(MAX_APPLICATION_CHAT_SEQUENCE).is_ok()); + assert!(ApplicationChatSequence::new(MAX_APPLICATION_CHAT_SEQUENCE + 1).is_err()); + assert!( + serde_json::from_value::(json!({ + "type": "user_message_accepted", + "status": "user_accepted", + "conversation_id": "chat-1", + "seq": MAX_APPLICATION_CHAT_SEQUENCE + 1, + "message_id": "message-user", + "operation_id": "operation-1" + })) + .is_err() + ); + } +} diff --git a/animus-config-protocol/Cargo.toml b/animus-config-protocol/Cargo.toml index 6cf285d..84af0a5 100644 --- a/animus-config-protocol/Cargo.toml +++ b/animus-config-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "animus-config-protocol" -version = "0.1.0" +version = "0.1.2" edition = "2021" description = "Wire types for the Animus `config_source` plugin role (JSON-RPC 2.0). Defines the canonical config model envelope a config source returns and the kernel compiles." @@ -13,6 +13,7 @@ anyhow = "1.0" sha2 = "0.10" protocol = { path = "../protocol" } animus-actor = { path = "../animus-actor" } +animus-application-protocol = { path = "../animus-application-protocol" } [dev-dependencies] tempfile = "3" diff --git a/animus-config-protocol/src/agent_types.rs b/animus-config-protocol/src/agent_types.rs index c450067..7b894c3 100644 --- a/animus-config-protocol/src/agent_types.rs +++ b/animus-config-protocol/src/agent_types.rs @@ -17,6 +17,13 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::Value; +use animus_application_protocol::validate_application_configured_ref; +pub use animus_application_protocol::{ + ApplicationPermissionIntent as ApplicationChatPermissionIntent, + ApplicationReasoningEffort as ApplicationChatReasoningEffort, + MAX_APPLICATION_CHAT_CONTROL_REF_BYTES, +}; + use crate::workflow_types::WorktreeConfig; /// `crate::types::*` in the original kernel module resolved to @@ -56,8 +63,13 @@ pub struct PhaseOutputContract { impl PhaseOutputContract { pub fn requires_field(&self, field: &str) -> bool { - self.required_fields.iter().any(|candidate| candidate.eq_ignore_ascii_case(field)) - || self.fields.iter().any(|(name, definition)| definition.required && name.eq_ignore_ascii_case(field)) + self.required_fields + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(field)) + || self + .fields + .iter() + .any(|(name, definition)| definition.required && name.eq_ignore_ascii_case(field)) } } @@ -234,7 +246,11 @@ pub struct AgentToolPolicy { impl AgentToolPolicy { pub fn is_tool_permitted(&self, tool_name: &str) -> bool { - let allowed = if self.allow.is_empty() { true } else { self.allow.iter().any(|p| glob_match(p, tool_name)) }; + let allowed = if self.allow.is_empty() { + true + } else { + self.allow.iter().any(|p| glob_match(p, tool_name)) + }; if !allowed { return false; @@ -374,10 +390,18 @@ pub struct ApprovalPolicy { impl ApprovalPolicy { pub fn evaluate(&self, subject: &str) -> ApprovalPolicyDecision { - if self.auto_deny.iter().any(|pattern| glob_match(pattern, subject)) { + if self + .auto_deny + .iter() + .any(|pattern| glob_match(pattern, subject)) + { return ApprovalPolicyDecision::Deny; } - if self.auto_allow.iter().any(|pattern| glob_match(pattern, subject)) { + if self + .auto_allow + .iter() + .any(|pattern| glob_match(pattern, subject)) + { return ApprovalPolicyDecision::Allow; } match self.default { @@ -398,7 +422,10 @@ pub fn glob_match(pattern: &str, value: &str) -> bool { fn glob_match_inner(pat: &[u8], val: &[u8]) -> bool { match (pat.first(), val.first()) { (None, None) => true, - (Some(b'*'), _) => glob_match_inner(&pat[1..], val) || (!val.is_empty() && glob_match_inner(pat, &val[1..])), + (Some(b'*'), _) => { + glob_match_inner(&pat[1..], val) + || (!val.is_empty() && glob_match_inner(pat, &val[1..])) + } (Some(&p), Some(&v)) if p == v => glob_match_inner(&pat[1..], &val[1..]), _ => false, } @@ -456,7 +483,11 @@ pub const AGENT_CAPABILITY_MEMORY: &str = "memory"; /// spawned agent's runtime contract. See [`AgentCapabilities`] for the catalog of recognized /// capability keys. pub fn agent_memory_capability_enabled(profile: &AgentProfile) -> bool { - profile.capabilities.get(AGENT_CAPABILITY_MEMORY).copied().unwrap_or(false) + profile + .capabilities + .get(AGENT_CAPABILITY_MEMORY) + .copied() + .unwrap_or(false) } #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -528,6 +559,144 @@ pub struct AgentProjectOverrides { pub env: BTreeMap, } +pub const MAX_APPLICATION_CHAT_CONTROL_SKILL_REFS: usize = 100; + +/// Portal-authored allowlist for typed application chat controls. +/// +/// `None` on an agent profile preserves the pre-policy config contract. Within +/// a declared policy, omitted lists inherit the canonical application defaults +/// while explicitly empty lists deny that control. +#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)] +pub struct ApplicationChatControlsPolicy { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_efforts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_intents: Option>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_permissive_intents: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_refs: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ApplicationChatControlsPolicyWire { + #[serde(default)] + approvals: Option, + #[serde(default)] + reasoning_efforts: Option>, + #[serde(default)] + permission_intents: Option>, + #[serde(default)] + allow_permissive_intents: bool, + #[serde(default)] + skill_refs: Option>, +} + +impl<'de> Deserialize<'de> for ApplicationChatControlsPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = ApplicationChatControlsPolicyWire::deserialize(deserializer)?; + let policy = Self { + approvals: wire.approvals, + reasoning_efforts: wire.reasoning_efforts, + permission_intents: wire.permission_intents, + allow_permissive_intents: wire.allow_permissive_intents, + skill_refs: wire.skill_refs, + }; + if !policy.within_bounds() { + return Err(serde::de::Error::custom( + "application_chat_controls exceeds bounds, contains duplicates, or has an invalid configured reference", + )); + } + Ok(policy) + } +} + +impl ApplicationChatControlsPolicy { + pub fn within_bounds(&self) -> bool { + self.reasoning_efforts + .as_ref() + .is_none_or(|values| values.len() <= 3 && no_duplicates(values)) + && self + .permission_intents + .as_ref() + .is_none_or(|values| values.len() <= 4 && no_duplicates(values)) + && self.skill_refs.as_ref().is_none_or(|values| { + values.len() <= MAX_APPLICATION_CHAT_CONTROL_SKILL_REFS + && no_duplicates(values) + && values + .iter() + .all(|value| valid_application_chat_control_ref(value)) + }) + } +} + +fn no_duplicates(values: &[T]) -> bool { + values + .iter() + .enumerate() + .all(|(index, value)| !values[..index].contains(value)) +} + +fn valid_application_chat_control_ref(value: &str) -> bool { + validate_application_configured_ref(value).is_ok() +} + +/// Presence-aware patch value for nullable profile fields. +/// +/// An omitted overlay field inherits, JSON/YAML `null` clears, and a concrete +/// value replaces. This is deliberately distinct from `Option`, whose +/// serde representation cannot distinguish omission from explicit null. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum AgentProfilePatch { + #[default] + Inherit, + Clear, + Set(T), +} + +impl AgentProfilePatch { + pub fn is_inherit(&self) -> bool { + matches!(self, Self::Inherit) + } + + pub fn into_set(self) -> Option { + match self { + Self::Set(value) => Some(value), + Self::Inherit | Self::Clear => None, + } + } +} + +impl Serialize for AgentProfilePatch { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Inherit | Self::Clear => serializer.serialize_none(), + Self::Set(value) => value.serialize(serializer), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for AgentProfilePatch { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Ok(match Option::::deserialize(deserializer)? { + Some(value) => Self::Set(value), + None => Self::Clear, + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AgentProfile { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -559,6 +728,8 @@ pub struct AgentProfile { pub hooks: AgentHooksConfig, #[serde(default)] pub skills: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub application_chat_controls: Option, #[serde(default)] pub capabilities: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -659,6 +830,8 @@ pub struct AgentProfileOverlay { pub hooks: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option>, + #[serde(default, skip_serializing_if = "AgentProfilePatch::is_inherit")] + pub application_chat_controls: AgentProfilePatch, #[serde(default, skip_serializing_if = "Option::is_none")] pub capabilities: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -763,6 +936,9 @@ impl AgentProfileOverlay { codex_config_overrides, max_continuations, ); + if !overlay.application_chat_controls.is_inherit() { + self.application_chat_controls = overlay.application_chat_controls.clone(); + } } } @@ -782,6 +958,9 @@ impl From for AgentProfileOverlay { approval_policy: profile.approval_policy, hooks: Some(profile.hooks), skills: Some(profile.skills), + application_chat_controls: profile + .application_chat_controls + .map_or(AgentProfilePatch::Clear, AgentProfilePatch::Set), capabilities: Some(profile.capabilities), mcp_server_configs: profile.mcp_server_configs, structured_capabilities: profile.structured_capabilities, @@ -1026,6 +1205,11 @@ pub fn merge_agent_profile(base: &mut AgentProfile, overlay: &AgentProfileOverla if let Some(skills) = &overlay.skills { base.skills = skills.clone(); } + match &overlay.application_chat_controls { + AgentProfilePatch::Inherit => {} + AgentProfilePatch::Clear => base.application_chat_controls = None, + AgentProfilePatch::Set(policy) => base.application_chat_controls = Some(policy.clone()), + } if let Some(capabilities) = &overlay.capabilities { base.capabilities = capabilities.clone(); } @@ -1113,9 +1297,13 @@ retry_on: no_retry_on: - auth_error "#; - let cfg: AgentRuntimeOverrides = serde_yaml::from_str(yaml).expect("parse runtime overrides"); + let cfg: AgentRuntimeOverrides = + serde_yaml::from_str(yaml).expect("parse runtime overrides"); assert_eq!(cfg.max_attempts, Some(5)); - assert_eq!(cfg.retry_on, vec!["transient".to_string(), "rate_limit".to_string()]); + assert_eq!( + cfg.retry_on, + vec!["transient".to_string(), "rate_limit".to_string()] + ); assert_eq!(cfg.no_retry_on, vec!["auth_error".to_string()]); } @@ -1123,10 +1311,17 @@ no_retry_on: fn back_compat_config_without_classification_fields_parses() { // A pre-existing config that never heard of retry_on / no_retry_on. let yaml = "max_attempts: 2\n"; - let cfg: AgentRuntimeOverrides = serde_yaml::from_str(yaml).expect("parse legacy runtime overrides"); + let cfg: AgentRuntimeOverrides = + serde_yaml::from_str(yaml).expect("parse legacy runtime overrides"); assert_eq!(cfg.max_attempts, Some(2)); - assert!(cfg.retry_on.is_empty(), "absent retry_on defaults to empty (retry-all behavior)"); - assert!(cfg.no_retry_on.is_empty(), "absent no_retry_on defaults to empty"); + assert!( + cfg.retry_on.is_empty(), + "absent retry_on defaults to empty (retry-all behavior)" + ); + assert!( + cfg.no_retry_on.is_empty(), + "absent no_retry_on defaults to empty" + ); } #[test] @@ -1135,8 +1330,14 @@ no_retry_on: // round-trips and golden artifacts stay byte-stable. let cfg = AgentRuntimeOverrides::default(); let json = serde_json::to_string(&cfg).expect("serialize default"); - assert!(!json.contains("retry_on"), "empty retry_on must be skipped: {json}"); - assert!(!json.contains("no_retry_on"), "empty no_retry_on must be skipped: {json}"); + assert!( + !json.contains("retry_on"), + "empty retry_on must be skipped: {json}" + ); + assert!( + !json.contains("no_retry_on"), + "empty no_retry_on must be skipped: {json}" + ); } #[test] @@ -1179,7 +1380,154 @@ no_retry_on: ..Default::default() }; merge_agent_profile(&mut base, &overlay); - assert_eq!(base.retry_on, vec!["network".to_string()], "overlay retry_on wins"); + assert_eq!( + base.retry_on, + vec!["network".to_string()], + "overlay retry_on wins" + ); assert_eq!(base.no_retry_on, vec!["validation".to_string()]); } } + +#[cfg(test)] +mod application_chat_controls_policy_tests { + use super::*; + + #[test] + fn omitted_policy_preserves_legacy_profile_serialization() { + let profile = AgentProfile::default(); + let json = serde_json::to_string(&profile).expect("serialize profile"); + assert!(!json.contains("application_chat_controls")); + + let legacy: AgentProfile = serde_json::from_value(serde_json::json!({ + "tool": "codex", + "skills": ["review"] + })) + .expect("deserialize legacy profile"); + assert!(legacy.application_chat_controls.is_none()); + } + + #[test] + fn typed_policy_round_trips_and_overlay_replaces_the_whole_policy() { + let overlay: AgentProfileOverlay = serde_json::from_value(serde_json::json!({ + "application_chat_controls": { + "approvals": false, + "reasoning_efforts": ["high"], + "permission_intents": ["default", "review"], + "skill_refs": ["security-review"] + } + })) + .expect("deserialize policy overlay"); + let declared = overlay + .application_chat_controls + .clone() + .into_set() + .expect("declared policy"); + assert!(declared.within_bounds()); + + let json = serde_json::to_string(&overlay).expect("serialize overlay"); + let round_trip: AgentProfileOverlay = + serde_json::from_str(&json).expect("deserialize overlay"); + assert_eq!( + round_trip.application_chat_controls, + AgentProfilePatch::Set(declared.clone()) + ); + + let mut base = AgentProfile { + application_chat_controls: Some(ApplicationChatControlsPolicy { + approvals: Some(true), + reasoning_efforts: Some(vec![ApplicationChatReasoningEffort::Low]), + permission_intents: Some(vec![ApplicationChatPermissionIntent::Unrestricted]), + allow_permissive_intents: true, + skill_refs: Some(vec!["old".to_string()]), + }), + ..Default::default() + }; + merge_agent_profile(&mut base, &round_trip); + assert_eq!(base.application_chat_controls, Some(declared)); + } + + #[test] + fn explicit_null_clears_policy_while_omission_inherits() { + let omitted: AgentProfileOverlay = serde_json::from_value(serde_json::json!({})).unwrap(); + let cleared: AgentProfileOverlay = serde_json::from_value(serde_json::json!({ + "application_chat_controls": null + })) + .unwrap(); + assert_eq!( + omitted.application_chat_controls, + AgentProfilePatch::Inherit + ); + assert_eq!(cleared.application_chat_controls, AgentProfilePatch::Clear); + + let mut layered = AgentProfileOverlay { + application_chat_controls: AgentProfilePatch::Set( + ApplicationChatControlsPolicy::default(), + ), + ..Default::default() + }; + layered.merge_from(&omitted); + assert!(matches!( + layered.application_chat_controls, + AgentProfilePatch::Set(_) + )); + layered.merge_from(&cleared); + assert_eq!(layered.application_chat_controls, AgentProfilePatch::Clear); + + let mut profile = AgentProfile { + application_chat_controls: Some(ApplicationChatControlsPolicy::default()), + ..Default::default() + }; + merge_agent_profile(&mut profile, &cleared); + assert!(profile.application_chat_controls.is_none()); + assert_eq!( + serde_json::to_value(&cleared).unwrap()["application_chat_controls"], + serde_json::Value::Null + ); + } + + #[test] + fn policy_bounds_reject_oversized_reference_sets_and_values() { + let too_many = ApplicationChatControlsPolicy { + skill_refs: Some(vec![ + "skill".to_string(); + MAX_APPLICATION_CHAT_CONTROL_SKILL_REFS + 1 + ]), + ..Default::default() + }; + assert!(!too_many.within_bounds()); + let too_long = ApplicationChatControlsPolicy { + skill_refs: Some(vec!["x".repeat(MAX_APPLICATION_CHAT_CONTROL_REF_BYTES + 1)]), + ..Default::default() + }; + assert!(!too_long.within_bounds()); + for refs in [ + vec!["duplicate".to_string(), "duplicate".to_string()], + vec!["../escape".to_string()], + vec!["not/allowed".to_string()], + vec!["é".to_string()], + ] { + assert!(!ApplicationChatControlsPolicy { + skill_refs: Some(refs), + ..Default::default() + } + .within_bounds()); + } + } + + #[test] + fn policy_deserialization_fails_closed_on_unknown_or_invalid_input() { + for value in [ + serde_json::json!({ "approval": true }), + serde_json::json!({ "reasoning_efforts": ["high", "high"] }), + serde_json::json!({ "permission_intents": ["review", "review"] }), + serde_json::json!({ "skill_refs": ["../escape"] }), + serde_json::json!({ "skill_refs": ["duplicate", "duplicate"] }), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "policy must reject {value}" + ); + } + } +} diff --git a/animus-config-protocol/src/bin/export_schema.rs b/animus-config-protocol/src/bin/export_schema.rs index c8ccaf2..0d3c383 100644 --- a/animus-config-protocol/src/bin/export_schema.rs +++ b/animus-config-protocol/src/bin/export_schema.rs @@ -24,8 +24,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use animus_config_protocol::{ - CacheToken, ConfigChangedEvent, ConfigDiagnostic, ConfigLoadRequest, ConfigLoadResponse, ConfigModel, - ConfigValidateRequest, ConfigValidateResponse, ConfigWriteRequest, ConfigWriteResponse, DiagnosticSeverity, + CacheToken, ConfigChangedEvent, ConfigDiagnostic, ConfigLoadRequest, ConfigLoadResponse, + ConfigModel, ConfigValidateRequest, ConfigValidateResponse, ConfigWriteRequest, + ConfigWriteResponse, DiagnosticSeverity, }; use schemars::{schema_for, Schema}; @@ -68,7 +69,10 @@ pub fn all_schemas() -> Vec<(&'static str, Schema)> { ("ConfigLoadResponse", schema_for!(ConfigLoadResponse)), ("ConfigChangedEvent", schema_for!(ConfigChangedEvent)), ("ConfigValidateRequest", schema_for!(ConfigValidateRequest)), - ("ConfigValidateResponse", schema_for!(ConfigValidateResponse)), + ( + "ConfigValidateResponse", + schema_for!(ConfigValidateResponse), + ), ("ConfigWriteRequest", schema_for!(ConfigWriteRequest)), ("ConfigWriteResponse", schema_for!(ConfigWriteResponse)), ("ConfigDiagnostic", schema_for!(ConfigDiagnostic)), @@ -150,17 +154,33 @@ mod tests { let raw = std::fs::read_to_string(&path).expect("schema file readable"); let value: Value = serde_json::from_str(&raw).expect("schema file parses"); assert!(value.is_object(), "{name} schema should be a JSON object"); - assert!(value.get("$schema").is_some(), "{name} schema should include $schema"); - assert!(value.get("title").is_some(), "{name} schema should include title"); + assert!( + value.get("$schema").is_some(), + "{name} schema should include $schema" + ); + assert!( + value.get("title").is_some(), + "{name} schema should include title" + ); } - let bundle_raw = std::fs::read_to_string(tmp.path().join("_all.json")).expect("bundle readable"); + let bundle_raw = + std::fs::read_to_string(tmp.path().join("_all.json")).expect("bundle readable"); let bundle: Value = serde_json::from_str(&bundle_raw).expect("bundle parses"); - let defs = bundle.get("$defs").and_then(|d| d.as_object()).expect("bundle has $defs"); + let defs = bundle + .get("$defs") + .and_then(|d| d.as_object()) + .expect("bundle has $defs"); for (name, _) in all_schemas() { - assert!(defs.contains_key(name), "bundle $defs should contain {name}"); + assert!( + defs.contains_key(name), + "bundle $defs should contain {name}" + ); } - assert!(bundle.get("$schema").is_some(), "bundle should advertise $schema"); + assert!( + bundle.get("$schema").is_some(), + "bundle should advertise $schema" + ); } #[test] @@ -170,7 +190,10 @@ mod tests { let type_field = value.get("type").expect("schema has a type field").clone(); assert!( type_field == Value::String("object".to_string()) - || type_field.as_array().map(|arr| arr.iter().any(|v| v == "object")).unwrap_or(false), + || type_field + .as_array() + .map(|arr| arr.iter().any(|v| v == "object")) + .unwrap_or(false), "ConfigModel schema should report object type, got {type_field}" ); } diff --git a/animus-config-protocol/src/builtins.rs b/animus-config-protocol/src/builtins.rs index 347b073..28ab5ea 100644 --- a/animus-config-protocol/src/builtins.rs +++ b/animus-config-protocol/src/builtins.rs @@ -38,10 +38,14 @@ pub fn builtin_workflow_config_base() -> WorkflowConfig { triggers: Vec::new(), daemon: None, secrets: BTreeMap::new(), + workspaces: BTreeMap::new(), + environment_routing: None, } } pub fn builtin_workflow_config() -> WorkflowConfig { static BUILTIN_CONFIG: OnceLock = OnceLock::new(); - BUILTIN_CONFIG.get_or_init(builtin_workflow_config_base).clone() + BUILTIN_CONFIG + .get_or_init(builtin_workflow_config_base) + .clone() } diff --git a/animus-config-protocol/src/env_interp.rs b/animus-config-protocol/src/env_interp.rs index b0c4196..9ad38a7 100644 --- a/animus-config-protocol/src/env_interp.rs +++ b/animus-config-protocol/src/env_interp.rs @@ -140,7 +140,9 @@ where copy_from = i; continue; } - let resolved = resolve_reference(body, source_label, &resolver, || line_number_for_offset(content, start))?; + let resolved = resolve_reference(body, source_label, &resolver, || { + line_number_for_offset(content, start) + })?; out.push_str(&resolved); i = body_start + close_off + 1; // skip past `}` copy_from = i; @@ -193,7 +195,10 @@ fn yaml_comment_spans(content: &str) -> Vec<(usize, usize)> { let line_end = line_start + line.len(); let stripped = line.strip_suffix('\n').unwrap_or(line); let bytes = stripped.as_bytes(); - let indent = bytes.iter().take_while(|b| **b == b' ' || **b == b'\t').count(); + let indent = bytes + .iter() + .take_while(|b| **b == b' ' || **b == b'\t') + .count(); let blank = indent == bytes.len(); if let Some(parent_indent) = block_scalar_indent { @@ -297,7 +302,10 @@ struct CommentSpans { impl CommentSpans { fn new(content: &str) -> Self { - Self { spans: yaml_comment_spans(content), next: 0 } + Self { + spans: yaml_comment_spans(content), + next: 0, + } } fn contains(&mut self, offset: usize) -> bool { @@ -326,7 +334,12 @@ fn find_matching_close(bytes: &[u8]) -> Option { None } -fn resolve_reference(body: &str, source_label: &str, resolver: &F, line_of: L) -> Result +fn resolve_reference( + body: &str, + source_label: &str, + resolver: &F, + line_of: L, +) -> Result where F: Fn(&str) -> Option, L: Fn() -> usize, @@ -356,7 +369,11 @@ where source_label, line_of(), name, - if message.is_empty() { "value is unset" } else { message } + if message.is_empty() { + "value is unset" + } else { + message + } )), }; } @@ -365,7 +382,12 @@ where validate_name(name, source_label, &line_of)?; match resolver(name) { Some(value) => Ok(value), - None => Err(anyhow!("workflow YAML at {} line {} references unset env var {}.", source_label, line_of(), name)), + None => Err(anyhow!( + "workflow YAML at {} line {} references unset env var {}.", + source_label, + line_of(), + name + )), } } @@ -380,7 +402,12 @@ where line_of() )); } - if !name.chars().next().map(|c| c == '_' || c.is_ascii_alphabetic()).unwrap_or(false) { + if !name + .chars() + .next() + .map(|c| c == '_' || c.is_ascii_alphabetic()) + .unwrap_or(false) + { return Err(anyhow!( "workflow YAML at {} line {} env var name `{}` must start with a letter or underscore", source_label, @@ -460,7 +487,10 @@ pub fn lint_sensitive_interpolations(content: &str, source_label: &str) -> Vec bool { return false; } let upper = name.to_ascii_uppercase(); - upper.contains("TOKEN") || upper.contains("KEY") || upper.contains("SECRET") || upper.contains("PASSWORD") + upper.contains("TOKEN") + || upper.contains("KEY") + || upper.contains("SECRET") + || upper.contains("PASSWORD") } fn line_number_for_offset(content: &str, offset: usize) -> usize { @@ -509,32 +542,46 @@ mod tests { /// Hermetic env stub: tests pass an explicit resolver instead of mutating /// process env, so they need no global lock. fn env_map(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { - let map: std::collections::BTreeMap = - pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(); + let map: std::collections::BTreeMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); move |key: &str| map.get(key).cloned() } #[test] fn expands_required_var() { - let out = - interpolate_env_with("api_token: ${KEY}\n", "test.yaml", env_map(&[("KEY", "secret-token")])).unwrap(); + let out = interpolate_env_with( + "api_token: ${KEY}\n", + "test.yaml", + env_map(&[("KEY", "secret-token")]), + ) + .unwrap(); assert_eq!(out, "api_token: secret-token\n"); } #[test] fn errors_clearly_when_required_var_unset() { let src = "a: 1\nb: 2\napi_token: ${KEY}\n"; - let err = interpolate_env_with(src, ".animus/workflows/agents.yaml", env_map(&[])).unwrap_err(); + let err = + interpolate_env_with(src, ".animus/workflows/agents.yaml", env_map(&[])).unwrap_err(); let msg = format!("{:#}", err); assert!(msg.contains("line 3"), "missing line number: {msg}"); assert!(msg.contains("KEY"), "missing var name: {msg}"); - assert!(msg.contains(".animus/workflows/agents.yaml"), "missing source label: {msg}"); + assert!( + msg.contains(".animus/workflows/agents.yaml"), + "missing source label: {msg}" + ); } #[test] fn uses_default_when_var_unset_with_default_syntax() { - let out = - interpolate_env_with("api_url: ${KEY:-https://api.example.com}\n", "test.yaml", env_map(&[])).unwrap(); + let out = interpolate_env_with( + "api_url: ${KEY:-https://api.example.com}\n", + "test.yaml", + env_map(&[]), + ) + .unwrap(); assert_eq!(out, "api_url: https://api.example.com\n"); } @@ -571,7 +618,10 @@ mod tests { let src = "a: ${KEY:?set this in your shell}\n"; let err = interpolate_env_with(src, "test.yaml", env_map(&[])).unwrap_err(); let msg = format!("{:#}", err); - assert!(msg.contains("set this in your shell"), "missing custom message: {msg}"); + assert!( + msg.contains("set this in your shell"), + "missing custom message: {msg}" + ); assert!(msg.contains("KEY")); } @@ -583,19 +633,24 @@ mod tests { let err = interpolate_env_with(src, "test.yaml", env_map(&[])).unwrap_err(); let msg = format!("{:#}", err); - assert!(msg.contains("missing key :-("), "missing custom message: {msg}"); + assert!( + msg.contains("missing key :-("), + "missing custom message: {msg}" + ); assert!(msg.contains("KEY"), "missing var name: {msg}"); } #[test] fn default_containing_required_token_parses_as_default() { - let out = interpolate_env_with("a: ${KEY:-fallback :? ok}\n", "test.yaml", env_map(&[])).unwrap(); + let out = + interpolate_env_with("a: ${KEY:-fallback :? ok}\n", "test.yaml", env_map(&[])).unwrap(); assert_eq!(out, "a: fallback :? ok\n"); } #[test] fn lone_dollar_passes_through() { - let out = interpolate_env_with("note: this costs $5 in total\n", "test.yaml", env_map(&[])).unwrap(); + let out = interpolate_env_with("note: this costs $5 in total\n", "test.yaml", env_map(&[])) + .unwrap(); assert_eq!(out, "note: this costs $5 in total\n"); } @@ -635,13 +690,15 @@ mod tests { fn leaves_secret_references_untouched() { // `${secret.*}` is no longer resolved at parse time — it survives // verbatim regardless of env contents. - let out = interpolate_env_with("token: ${secret.api}\n", "test.yaml", env_map(&[])).unwrap(); + let out = + interpolate_env_with("token: ${secret.api}\n", "test.yaml", env_map(&[])).unwrap(); assert_eq!(out, "token: ${secret.api}\n"); } #[test] fn preserves_escaped_literal_secret_reference() { - let out = interpolate_env_with("prompt: $${secret.api}\n", "test.yaml", env_map(&[])).unwrap(); + let out = + interpolate_env_with("prompt: $${secret.api}\n", "test.yaml", env_map(&[])).unwrap(); assert_eq!(out, "prompt: $${secret.api}\n"); } @@ -668,9 +725,12 @@ mod tests { #[test] fn hash_inside_quoted_scalar_still_interpolates() { - let out = - interpolate_env_with("key: \"#not-a-comment ${KEY}\"\n", "test.yaml", env_map(&[("KEY", "expanded")])) - .unwrap(); + let out = interpolate_env_with( + "key: \"#not-a-comment ${KEY}\"\n", + "test.yaml", + env_map(&[("KEY", "expanded")]), + ) + .unwrap(); assert_eq!(out, "key: \"#not-a-comment expanded\"\n"); } @@ -727,14 +787,21 @@ mod tests { fn lint_skips_sensitive_looking_references_in_comments() { let src = "# export ${LINEAR_TOKEN}\nurl: ${TEAM_URL:-https://example.com}\n"; let warnings = lint_sensitive_interpolations(src, "test.yaml"); - assert!(warnings.is_empty(), "comment-only reference should not warn: {warnings:?}"); + assert!( + warnings.is_empty(), + "comment-only reference should not warn: {warnings:?}" + ); } #[test] fn lint_flags_sensitive_looking_interpolations() { let src = "token: ${LINEAR_TOKEN}\n"; let warnings = lint_sensitive_interpolations(src, "test.yaml"); - assert_eq!(warnings.len(), 1, "expected one sensitive-interpolation warning: {warnings:?}"); + assert_eq!( + warnings.len(), + 1, + "expected one sensitive-interpolation warning: {warnings:?}" + ); assert!(warnings[0].contains("LINEAR_TOKEN")); } } diff --git a/animus-config-protocol/src/overlay.rs b/animus-config-protocol/src/overlay.rs index e44516a..4bcb796 100644 --- a/animus-config-protocol/src/overlay.rs +++ b/animus-config-protocol/src/overlay.rs @@ -27,14 +27,20 @@ pub(crate) fn write_yaml_workflow_overlay( yaml_file: &YamlWorkflowFile, ) -> Result { let workflows_dir = yaml_workflows_dir(project_root); - fs::create_dir_all(&workflows_dir).with_context(|| format!("failed to create {}", workflows_dir.display()))?; + fs::create_dir_all(&workflows_dir) + .with_context(|| format!("failed to create {}", workflows_dir.display()))?; let path = workflows_dir.join(file_name); - let content = serde_yaml::to_string(yaml_file).context("failed to serialize workflow YAML overlay")?; + let content = + serde_yaml::to_string(yaml_file).context("failed to serialize workflow YAML overlay")?; fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?; Ok(path) } -pub fn write_workflow_yaml_overlay(project_root: &Path, file_name: &str, config: &WorkflowConfig) -> Result { +pub fn write_workflow_yaml_overlay( + project_root: &Path, + file_name: &str, + config: &WorkflowConfig, +) -> Result { let yaml_file = workflow_config_to_yaml_file(config); write_yaml_workflow_overlay(project_root, file_name, &yaml_file) } @@ -43,13 +49,18 @@ pub fn write_workflow_yaml_overlay(project_root: &Path, file_name: &str, config: /// parse as an empty overlay. `${VAR}` / `${secret.X}` references in the /// file survive verbatim, so round-tripping through this reader never bakes /// resolved values into the project tree. -fn read_yaml_workflow_overlay_raw(project_root: &Path, file_name: &str) -> Result { +fn read_yaml_workflow_overlay_raw( + project_root: &Path, + file_name: &str, +) -> Result { let path = yaml_workflows_dir(project_root).join(file_name); if !path.exists() { return Ok(YamlWorkflowFile::default()); } - let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - serde_yaml::from_str(&content).with_context(|| format!("failed to parse generated overlay {}", path.display())) + let content = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse generated overlay {}", path.display())) } /// Load the generated workflow overlay, keeping only the blocks the @@ -80,20 +91,33 @@ pub fn upsert_generated_workflow_phase( catalog_entry: Option<&PhaseUiDefinition>, ) -> Result { let mut file = read_generated_workflow_authored_blocks(project_root)?; - file.phases.retain(|existing, _| !existing.eq_ignore_ascii_case(phase_id)); - file.phases.insert(phase_id.to_string(), phase_execution_definition_to_yaml(definition)); + file.phases + .retain(|existing, _| !existing.eq_ignore_ascii_case(phase_id)); + file.phases.insert( + phase_id.to_string(), + phase_execution_definition_to_yaml(definition), + ); if let Some(entry) = catalog_entry { - file.phase_catalog.get_or_insert_with(BTreeMap::new).insert(phase_id.to_string(), entry.clone()); + file.phase_catalog + .get_or_insert_with(BTreeMap::new) + .insert(phase_id.to_string(), entry.clone()); } write_yaml_workflow_overlay(project_root, GENERATED_WORKFLOW_OVERLAY_FILE_NAME, &file) } /// Upsert a single pipeline definition into the generated workflow overlay. /// Same unresolved round-trip contract as [`upsert_generated_workflow_phase`]. -pub fn upsert_generated_workflow_pipeline(project_root: &Path, pipeline: &WorkflowDefinition) -> Result { +pub fn upsert_generated_workflow_pipeline( + project_root: &Path, + pipeline: &WorkflowDefinition, +) -> Result { let mut file = read_generated_workflow_authored_blocks(project_root)?; let yaml_pipeline = workflow_definition_to_yaml(pipeline); - if let Some(existing) = file.workflows.iter_mut().find(|existing| existing.id.eq_ignore_ascii_case(&pipeline.id)) { + if let Some(existing) = file + .workflows + .iter_mut() + .find(|existing| existing.id.eq_ignore_ascii_case(&pipeline.id)) + { *existing = yaml_pipeline; } else { file.workflows.push(yaml_pipeline); @@ -105,12 +129,19 @@ pub fn upsert_generated_workflow_pipeline(project_root: &Path, pipeline: &Workfl /// `animus workflow phases remove` can only prune overlay-defined phases, so /// the dry-run preview uses this to report removability accurately. pub fn generated_workflow_phase_is_defined(project_root: &Path, phase_id: &str) -> Result { - for file_name in [GENERATED_WORKFLOW_OVERLAY_FILE_NAME, GENERATED_RUNTIME_OVERLAY_FILE_NAME] { + for file_name in [ + GENERATED_WORKFLOW_OVERLAY_FILE_NAME, + GENERATED_RUNTIME_OVERLAY_FILE_NAME, + ] { if !yaml_workflows_dir(project_root).join(file_name).exists() { continue; } let file = read_yaml_workflow_overlay_raw(project_root, file_name)?; - if file.phases.keys().any(|existing| existing.eq_ignore_ascii_case(phase_id)) { + if file + .phases + .keys() + .any(|existing| existing.eq_ignore_ascii_case(phase_id)) + { return Ok(true); } } @@ -123,13 +154,17 @@ pub fn generated_workflow_phase_is_defined(project_root: &Path, phase_id: &str) /// or pack and must be removed there. pub fn remove_generated_workflow_phase(project_root: &Path, phase_id: &str) -> Result { let mut removed = false; - for file_name in [GENERATED_WORKFLOW_OVERLAY_FILE_NAME, GENERATED_RUNTIME_OVERLAY_FILE_NAME] { + for file_name in [ + GENERATED_WORKFLOW_OVERLAY_FILE_NAME, + GENERATED_RUNTIME_OVERLAY_FILE_NAME, + ] { if !yaml_workflows_dir(project_root).join(file_name).exists() { continue; } let mut file = read_yaml_workflow_overlay_raw(project_root, file_name)?; let phase_count = file.phases.len(); - file.phases.retain(|existing, _| !existing.eq_ignore_ascii_case(phase_id)); + file.phases + .retain(|existing, _| !existing.eq_ignore_ascii_case(phase_id)); let mut changed = file.phases.len() != phase_count; if let Some(catalog) = file.phase_catalog.as_mut() { let catalog_count = catalog.len(); diff --git a/animus-config-protocol/src/parse.rs b/animus-config-protocol/src/parse.rs index 879ee39..0db6e34 100644 --- a/animus-config-protocol/src/parse.rs +++ b/animus-config-protocol/src/parse.rs @@ -21,7 +21,8 @@ use crate::builtins::builtin_workflow_config; use crate::env_interp::{interpolate_env, lint_sensitive_interpolations}; use crate::workflow_types::*; use crate::yaml_parser::{ - parse_yaml_workflow_config_confined_to_pack, parse_yaml_workflow_config_with_base_source_and_original, + parse_yaml_workflow_config_confined_to_pack, + parse_yaml_workflow_config_with_base_source_and_original, }; pub const YAML_WORKFLOWS_DIR: &str = "workflows"; @@ -33,15 +34,17 @@ pub fn yaml_workflows_dir(project_root: &Path) -> PathBuf { /// Collect the project's `.animus` YAML sources in deterministic order: /// `.animus/workflows.yaml` first, then `.animus/workflows/*.{yaml,yml}` sorted. -pub fn collect_project_yaml_workflow_sources(project_root: &Path) -> Result> { +pub fn collect_project_yaml_workflow_sources( + project_root: &Path, +) -> Result> { let workflows_dir = yaml_workflows_dir(project_root); let single_file = project_root.join(".animus").join("workflows.yaml"); let mut yaml_sources: Vec<(PathBuf, String)> = Vec::new(); if single_file.exists() { - let content = - fs::read_to_string(&single_file).with_context(|| format!("failed to read {}", single_file.display()))?; + let content = fs::read_to_string(&single_file) + .with_context(|| format!("failed to read {}", single_file.display()))?; yaml_sources.push((single_file, content)); } @@ -49,13 +52,20 @@ pub fn collect_project_yaml_workflow_sources(project_root: &Path) -> Result = fs::read_dir(&workflows_dir) .with_context(|| format!("failed to read directory {}", workflows_dir.display()))? .filter_map(|entry| entry.ok()) - .filter(|entry| entry.path().extension().map(|ext| ext == "yaml" || ext == "yml").unwrap_or(false)) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) .collect(); entries.sort_by_key(|e| e.path()); for entry in entries { let path = entry.path(); - let content = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let content = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; yaml_sources.push((path, content)); } } @@ -150,7 +160,10 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor let mut workflows = base.workflows; for yaml_pipeline in yaml.workflows { - if let Some(pos) = workflows.iter().position(|p| p.id.eq_ignore_ascii_case(&yaml_pipeline.id)) { + if let Some(pos) = workflows + .iter() + .position(|p| p.id.eq_ignore_ascii_case(&yaml_pipeline.id)) + { workflows[pos] = yaml_pipeline; } else { workflows.push(yaml_pipeline); @@ -206,9 +219,11 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor let mut schedules = base.schedules; for overlay_schedule in yaml.schedules { - if let Some(pos) = - schedules.iter().position(|schedule| schedule.id.eq_ignore_ascii_case(overlay_schedule.id.as_str())) - { + if let Some(pos) = schedules.iter().position(|schedule| { + schedule + .id + .eq_ignore_ascii_case(overlay_schedule.id.as_str()) + }) { schedules[pos] = overlay_schedule; } else { schedules.push(overlay_schedule); @@ -217,8 +232,9 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor let mut triggers = base.triggers; for overlay_trigger in yaml.triggers { - if let Some(pos) = - triggers.iter().position(|trigger| trigger.id.eq_ignore_ascii_case(overlay_trigger.id.as_str())) + if let Some(pos) = triggers + .iter() + .position(|trigger| trigger.id.eq_ignore_ascii_case(overlay_trigger.id.as_str())) { triggers[pos] = overlay_trigger; } else { @@ -241,12 +257,13 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor (None, Some(overlay)) => Some(overlay), }; - let default_workflow_ref = - if yaml.default_workflow_ref != base.default_workflow_ref && !yaml.default_workflow_ref.is_empty() { - yaml.default_workflow_ref - } else { - base.default_workflow_ref - }; + let default_workflow_ref = if yaml.default_workflow_ref != base.default_workflow_ref + && !yaml.default_workflow_ref.is_empty() + { + yaml.default_workflow_ref + } else { + base.default_workflow_ref + }; let daemon = match (base.daemon, yaml.daemon) { (None, None) => None, @@ -274,6 +291,13 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor secrets.insert(key, value); } + let mut workspaces = base.workspaces; + for (name, workspace) in yaml.workspaces { + workspaces.insert(name, workspace); + } + + let environment_routing = yaml.environment_routing.or(base.environment_routing); + WorkflowConfig { schema: WORKFLOW_CONFIG_SCHEMA_ID.to_string(), version: WORKFLOW_CONFIG_VERSION, @@ -293,5 +317,7 @@ pub fn merge_yaml_into_config(base: WorkflowConfig, yaml: WorkflowConfig) -> Wor triggers, daemon, secrets, + workspaces, + environment_routing, } } diff --git a/animus-config-protocol/src/workflow_types.rs b/animus-config-protocol/src/workflow_types.rs index b0617b8..d60f866 100644 --- a/animus-config-protocol/src/workflow_types.rs +++ b/animus-config-protocol/src/workflow_types.rs @@ -102,6 +102,16 @@ pub struct WorkflowPhaseConfig { pub skip_if: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub budget: Option, + /// Environment plugin id (or an [`EnvironmentRouting`] rule key) this phase + /// should run in, overriding the workflow- and config-level defaults. + /// `None` falls through to the workflow's `environment`, then to + /// [`EnvironmentRouting`]. See [[TASK-163]]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + /// Named [`Workspace`] (repo set) this phase runs against, overriding the + /// workflow-level `workspace`. `None` inherits the workflow default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -219,7 +229,11 @@ pub struct WorktreeConfig { impl Default for WorktreeConfig { fn default() -> Self { - Self { mode: WorktreeMode::Auto, cleanup: default_worktree_cleanup(), base_ref: None } + Self { + mode: WorktreeMode::Auto, + cleanup: default_worktree_cleanup(), + base_ref: None, + } } } @@ -229,11 +243,19 @@ pub(crate) fn default_worktree_cleanup() -> bool { impl WorktreeConfig { pub fn skip() -> Self { - Self { mode: WorktreeMode::Skip, cleanup: default_worktree_cleanup(), base_ref: None } + Self { + mode: WorktreeMode::Skip, + cleanup: default_worktree_cleanup(), + base_ref: None, + } } pub fn required() -> Self { - Self { mode: WorktreeMode::Required, cleanup: default_worktree_cleanup(), base_ref: None } + Self { + mode: WorktreeMode::Required, + cleanup: default_worktree_cleanup(), + base_ref: None, + } } pub(crate) fn parse_mode(value: &str) -> Result { @@ -241,7 +263,10 @@ impl WorktreeConfig { "auto" => Ok(WorktreeMode::Auto), "required" => Ok(WorktreeMode::Required), "skip" => Ok(WorktreeMode::Skip), - other => Err(anyhow!("invalid worktree mode '{}' (expected auto, required, or skip)", other)), + other => Err(anyhow!( + "invalid worktree mode '{}' (expected auto, required, or skip)", + other + )), } } @@ -251,18 +276,106 @@ impl WorktreeConfig { pub(crate) fn from_yaml(yaml: crate::yaml_types::YamlPhaseWorktree) -> Result { match yaml { crate::yaml_types::YamlPhaseWorktree::Bool(flag) => { - let mode = if flag { WorktreeMode::Auto } else { WorktreeMode::Skip }; - Ok(Self { mode, cleanup: default_worktree_cleanup(), base_ref: None }) + let mode = if flag { + WorktreeMode::Auto + } else { + WorktreeMode::Skip + }; + Ok(Self { + mode, + cleanup: default_worktree_cleanup(), + base_ref: None, + }) } crate::yaml_types::YamlPhaseWorktree::Mode(scalar) => { let mode = Self::parse_mode(&scalar)?; - Ok(Self { mode, cleanup: default_worktree_cleanup(), base_ref: None }) + Ok(Self { + mode, + cleanup: default_worktree_cleanup(), + base_ref: None, + }) } crate::yaml_types::YamlPhaseWorktree::Full(config) => Ok(config), } } } +/// A single repository in a named [`Workspace`] (repo set). Mirrors the +/// `RepoRef` wire type in `animus-environment-protocol`; this is the +/// YAML/postgres-authorable config form the kernel compiles into an +/// `EnvironmentSpec`. See [[TASK-157]]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceRepo { + /// Clone URL or local path for the repository. + pub url: String, + /// Subdirectory to check the repo out under. Defaults to the last path + /// segment of [`Self::url`] when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Git ref (branch, tag, or commit) to check out. Defaults to the remote's + /// default branch when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_ref: Option, + /// Marks the primary repo in the set (the default command `cwd`). At most + /// one repo should be primary; when none is, the first entry wins. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub primary: bool, +} + +/// A named repo set an environment materializes as a single workspace. +/// Referenced by name from `workflow.workspace` / `phase.workspace`. See +/// [[TASK-157]]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct Workspace { + /// Repositories that make up the workspace, each checked out under its own + /// subdirectory in the environment's workspace root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repos: Vec, +} + +/// Config-level environment routing: the default environment plugin and an +/// ordered list of match rules. The kernel evaluates [`Self::rules`] top-to- +/// bottom and falls back to [`Self::default`] when none match. See [[TASK-163]]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct EnvironmentRouting { + /// Environment plugin id used when no rule matches (and no workflow/phase + /// override applies). `None` means "no explicit environment" — the runner + /// falls back to its built-in local behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Ordered match rules, evaluated first-match-wins. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +/// One environment-routing rule: a match predicate plus the environment (and +/// optional spec overrides) to use when it matches. See [[TASK-163]]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EnvironmentRule { + /// Predicate this rule matches on. An empty match matches everything. + #[serde(rename = "match", default)] + pub match_on: EnvironmentMatch, + /// Environment plugin id to route matching work to. + pub environment: String, + /// Optional spec overrides (image, resources, env, ...) merged into the + /// compiled `EnvironmentSpec` for matching work. Carried opaquely. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec: Option>, +} + +/// Match predicate for an [`EnvironmentRule`]. Fields are ANDed; an unset field +/// is a wildcard. An all-unset match matches everything (useful as a +/// catch-all). See [[TASK-163]]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct EnvironmentMatch { + /// Match on subject kind (e.g. `"task"`, `"requirement"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Match on harness / provider tool id (e.g. `"claude"`, `"codex"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, +} + /// Top-level declarative secret reference. `${secret.}` interpolation /// resolves the named env var at compile time; required-but-unset fails the /// compile with a file path + line number diagnostic. @@ -292,20 +405,39 @@ pub struct WorkflowDefinition { #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, pub budget: Option, + /// Environment plugin id (or an [`EnvironmentRouting`] rule key) every phase + /// in this workflow runs in unless the phase overrides it. `None` falls + /// through to [`EnvironmentRouting`]. See [[TASK-163]]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment: Option, + /// Named [`Workspace`] (repo set) this workflow runs against. `None` uses + /// the environment's default single-repo workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, } impl WorkflowDefinition { pub fn phase_ids(&self) -> Vec { - self.phases.iter().map(|entry| entry.phase_id().trim().to_owned()).filter(|id| !id.is_empty()).collect() + self.phases + .iter() + .map(|entry| entry.phase_id().trim().to_owned()) + .filter(|id| !id.is_empty()) + .collect() } } -pub fn expand_workflow_phases(workflows: &[WorkflowDefinition], workflow_ref: &str) -> Result> { +pub fn expand_workflow_phases( + workflows: &[WorkflowDefinition], + workflow_ref: &str, +) -> Result> { let mut visited = HashSet::new(); expand_workflow_phases_inner(workflows, workflow_ref, &mut visited) } -pub fn collect_workflow_refs(workflows: &[WorkflowDefinition], workflow_ref: &str) -> Result> { +pub fn collect_workflow_refs( + workflows: &[WorkflowDefinition], + workflow_ref: &str, +) -> Result> { let mut active = HashSet::new(); let mut seen = HashSet::new(); let mut refs = Vec::new(); @@ -372,7 +504,8 @@ fn expand_workflow_phases_inner( for entry in &workflow.phases { match entry { WorkflowPhaseEntry::SubWorkflow(sub) => { - let sub_phases = expand_workflow_phases_inner(workflows, &sub.workflow_ref, visited)?; + let sub_phases = + expand_workflow_phases_inner(workflows, &sub.workflow_ref, visited)?; expanded.extend(sub_phases); } other => { @@ -404,7 +537,10 @@ pub fn resolve_workflow_variables( if !missing.is_empty() { missing.sort(); - return Err(anyhow!("missing required workflow variable(s): {}", missing.join(", "))); + return Err(anyhow!( + "missing required workflow variable(s): {}", + missing.join(", ") + )); } Ok(resolved) @@ -622,6 +758,26 @@ pub struct WorkflowSchedule { pub input: Option, #[serde(default = "default_schedule_enabled")] pub enabled: bool, + /// Optional config-declared owner. When set, the daemon scheduler mints a + /// system [`Actor`](animus_actor::Actor) for this `user_id` and runs the + /// dispatched workflow as that user (resolving their config partition and + /// integrations). `None` keeps the legacy global (actor-less) dispatch. + /// + /// TRUST BOUNDARY: the owner is asserted at config-authoring time — the + /// workflow config is itself owner-scoped / admin-authored (e.g. served by + /// `config-postgres` team_* rows or admin-curated YAML), never derived from + /// runtime or agent-generated content. Minting an actor here therefore + /// respects the transport-asserted-identity model: it is the one place the + /// kernel constructs an actor rather than relaying one, and the assertion + /// originates from a trusted, authored source. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_id: Option, + /// Optional advisory claims minted alongside [`owner_id`](Self::owner_id) + /// (e.g. `["admin"]`). Ignored when `owner_id` is `None`. Mirrors + /// [`Actor::claims`](animus_actor::Actor::claims): advisory only, the + /// kernel never branches on them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub claims: Vec, } pub(crate) fn default_schedule_enabled() -> bool { @@ -828,6 +984,14 @@ pub struct WorkflowConfig { pub daemon: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub secrets: BTreeMap, + /// Named repo sets ([`Workspace`]) workflows/phases can reference by name. + /// See [[TASK-157]]. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub workspaces: BTreeMap, + /// Config-level environment routing (default + match rules). Workflow- and + /// phase-level `environment` overrides win over these. See [[TASK-163]]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_routing: Option, } impl Default for WorkflowConfig { @@ -841,6 +1005,10 @@ impl Default for WorkflowConfig { pub enum WorkflowConfigSource { Json, Yaml, + /// Base config acquired from an installed `config_source` plugin + /// (e.g. animus-config-postgres). The on-disk `path` is just the + /// project root, not a real YAML file. See TASK-177. + Plugin, Builtin, BuiltinFallback, } @@ -850,6 +1018,7 @@ impl WorkflowConfigSource { match self { Self::Json => "json", Self::Yaml => "yaml", + Self::Plugin => "plugin", Self::Builtin => "builtin", Self::BuiltinFallback => "builtin_fallback", } diff --git a/animus-config-protocol/src/yaml_diagnostic.rs b/animus-config-protocol/src/yaml_diagnostic.rs index 8763e89..3bde2fb 100644 --- a/animus-config-protocol/src/yaml_diagnostic.rs +++ b/animus-config-protocol/src/yaml_diagnostic.rs @@ -103,7 +103,13 @@ impl YamlDiagnostic { /// `col_start` and `col_end` are 1-based column positions on `line` to /// underline (inclusive..exclusive). If `col_end <= col_start`, the /// underline is widened to cover the rest of the line. - pub fn with_excerpt_from(mut self, yaml_str: &str, line: usize, col_start: usize, col_end: usize) -> Self { + pub fn with_excerpt_from( + mut self, + yaml_str: &str, + line: usize, + col_start: usize, + col_end: usize, + ) -> Self { if line == 0 { return self; } @@ -114,13 +120,25 @@ impl YamlDiagnostic { let focal_idx = line.saturating_sub(1).min(all_lines.len() - 1); let start_idx = focal_idx.saturating_sub(1); let end_idx = (focal_idx + 1).min(all_lines.len() - 1); - let lines: Vec = all_lines[start_idx..=end_idx].iter().map(|s| s.to_string()).collect(); + let lines: Vec = all_lines[start_idx..=end_idx] + .iter() + .map(|s| s.to_string()) + .collect(); let focal = focal_idx - start_idx; let focal_len = all_lines[focal_idx].chars().count(); let cs = col_start.saturating_sub(1).min(focal_len); - let ce = if col_end > col_start { col_end.saturating_sub(1).min(focal_len) } else { focal_len }; + let ce = if col_end > col_start { + col_end.saturating_sub(1).min(focal_len) + } else { + focal_len + }; let ce = ce.max(cs + 1).min(focal_len.max(cs + 1)); - self.excerpt = Some(YamlExcerpt { start_line: start_idx + 1, lines, underline: (cs, ce), focal }); + self.excerpt = Some(YamlExcerpt { + start_line: start_idx + 1, + lines, + underline: (cs, ce), + focal, + }); self } } @@ -150,7 +168,11 @@ impl fmt::Display for YamlDiagnostic { writeln!(f, "{} | {}", num, line)?; if offset == excerpt.focal { let (cs, ce) = excerpt.underline; - let leading: String = line.chars().take(cs).map(|c| if c == '\t' { '\t' } else { ' ' }).collect(); + let leading: String = line + .chars() + .take(cs) + .map(|c| if c == '\t' { '\t' } else { ' ' }) + .collect(); let span = ce.saturating_sub(cs).max(1); let carets: String = "^".repeat(span); if self.expected.is_empty() { @@ -201,7 +223,11 @@ pub fn edit_distance(a: &str, b: &str) -> usize { for i in 1..=n { curr[0] = i; for j in 1..=m { - let cost = if a[i - 1].eq_ignore_ascii_case(&b[j - 1]) { 0 } else { 1 }; + let cost = if a[i - 1].eq_ignore_ascii_case(&b[j - 1]) { + 0 + } else { + 1 + }; curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost); } std::mem::swap(&mut prev, &mut curr); @@ -211,7 +237,11 @@ pub fn edit_distance(a: &str, b: &str) -> usize { /// Choose the closest candidate to `input` within `max_distance` (Levenshtein). /// Returns `None` if no candidate is within range. -pub fn closest_match<'a>(input: &str, candidates: &[&'a str], max_distance: usize) -> Option<&'a str> { +pub fn closest_match<'a>( + input: &str, + candidates: &[&'a str], + max_distance: usize, +) -> Option<&'a str> { let mut best: Option<(&'a str, usize)> = None; for cand in candidates { let d = edit_distance(input, cand); @@ -227,7 +257,11 @@ pub fn closest_match<'a>(input: &str, candidates: &[&'a str], max_distance: usiz /// Wrap a raw `serde_yaml::Error` into a `YamlDiagnostic`, attaching the /// file path and a source excerpt when location info is available. -pub fn wrap_serde_yaml_error(err: &serde_yaml::Error, yaml_str: &str, source_path: Option<&Path>) -> YamlDiagnostic { +pub fn wrap_serde_yaml_error( + err: &serde_yaml::Error, + yaml_str: &str, + source_path: Option<&Path>, +) -> YamlDiagnostic { let mut diag = YamlDiagnostic::new("yaml.parse_failed", err.to_string()); if let Some(path) = source_path { diag = diag.with_file(path.to_path_buf()); @@ -237,7 +271,9 @@ pub fn wrap_serde_yaml_error(err: &serde_yaml::Error, yaml_str: &str, source_pat let col = loc.column(); let trimmed_msg = strip_trailing_location(&diag.message); diag.message = trimmed_msg; - diag = diag.with_location(line, col).with_excerpt_from(yaml_str, line, col, col + 1); + diag = diag + .with_location(line, col) + .with_excerpt_from(yaml_str, line, col, col + 1); } diag } @@ -280,7 +316,10 @@ mod tests { let diag = YamlDiagnostic::new("yaml.invalid_worktree", "invalid `worktree:` value") .with_file("/tmp/x.yaml") .with_location(3, 5) - .with_expected(vec!["string: \"auto\" | \"required\" | \"skip\"", "boolean: true | false"]) + .with_expected(vec![ + "string: \"auto\" | \"required\" | \"skip\"", + "boolean: true | false", + ]) .with_suggestion("false") .with_excerpt_from(yaml, 3, 5, 19); let rendered = format!("{}", diag); diff --git a/animus-config-protocol/src/yaml_parser.rs b/animus-config-protocol/src/yaml_parser.rs index bbec56c..ebaba5a 100644 --- a/animus-config-protocol/src/yaml_parser.rs +++ b/animus-config-protocol/src/yaml_parser.rs @@ -6,8 +6,8 @@ use anyhow::{anyhow, Context, Result}; use crate::agent_types::PhaseExecutionDefinition; use crate::agent_types::{ - AgentProfileOverlay, CommandCwdMode, EvalCheck, EvalsConfig, PhaseCommandDefinition, PhaseExecutionMode, - PhaseManualDefinition, + AgentProfileOverlay, CommandCwdMode, EvalCheck, EvalsConfig, PhaseCommandDefinition, + PhaseExecutionMode, PhaseManualDefinition, }; use crate::builtins::builtin_workflow_config; @@ -47,7 +47,12 @@ pub fn resolve_agent_model_references( } if let Some(entry) = registry.get(trimmed) { let model = entry.model.trim().to_string(); - let tool = entry.tool.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(ToOwned::to_owned); + let tool = entry + .tool + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned); resolved_models.push(model); resolved_tools.push(tool); } else { @@ -99,7 +104,10 @@ pub(super) fn parse_cwd_mode(value: &str) -> Result { "project_root" => Ok(CommandCwdMode::ProjectRoot), "task_root" => Ok(CommandCwdMode::TaskRoot), "path" => Ok(CommandCwdMode::Path), - other => Err(anyhow!("unknown cwd_mode '{}' (expected project_root, task_root, or path)", other)), + other => Err(anyhow!( + "unknown cwd_mode '{}' (expected project_root, task_root, or path)", + other + )), } } @@ -169,7 +177,12 @@ pub(super) fn yaml_phase_to_execution_definition( program: cmd.program, args: cmd.args, env: cmd.env, - cwd_mode: cmd.cwd_mode.as_deref().map(parse_cwd_mode).transpose()?.unwrap_or(CommandCwdMode::ProjectRoot), + cwd_mode: cmd + .cwd_mode + .as_deref() + .map(parse_cwd_mode) + .transpose()? + .unwrap_or(CommandCwdMode::ProjectRoot), cwd_path: cmd.cwd_path, timeout_secs: cmd.timeout_secs, success_exit_codes: cmd.success_exit_codes.unwrap_or_else(|| vec![0]), @@ -185,7 +198,10 @@ pub(super) fn yaml_phase_to_execution_definition( failure_risk: cmd.failure_risk, }), (PhaseExecutionMode::Command, None) => { - return Err(anyhow!("phases['{}'] mode 'command' requires a command block", phase_id)); + return Err(anyhow!( + "phases['{}'] mode 'command' requires a command block", + phase_id + )); } (_, Some(_)) => { return Err(anyhow!( @@ -204,10 +220,17 @@ pub(super) fn yaml_phase_to_execution_definition( timeout_secs: m.timeout_secs, }), (PhaseExecutionMode::Manual, None) => { - return Err(anyhow!("phases['{}'] mode 'manual' requires a manual block", phase_id)); + return Err(anyhow!( + "phases['{}'] mode 'manual' requires a manual block", + phase_id + )); } (_, Some(_)) => { - return Err(anyhow!("phases['{}'] mode '{}' must not include a manual block", phase_id, mode_label)); + return Err(anyhow!( + "phases['{}'] mode '{}' must not include a manual block", + phase_id, + mode_label + )); } _ => None, }; @@ -236,9 +259,9 @@ pub(super) fn yaml_phase_to_execution_definition( pub(super) fn workflow_phase_entry_to_yaml(entry: &WorkflowPhaseEntry) -> YamlPhaseEntry { match entry { WorkflowPhaseEntry::Simple(id) => YamlPhaseEntry::Simple(id.clone()), - WorkflowPhaseEntry::SubWorkflow(sub) => { - YamlPhaseEntry::SubWorkflow(YamlSubWorkflowRef { workflow_ref: sub.workflow_ref.clone() }) - } + WorkflowPhaseEntry::SubWorkflow(sub) => YamlPhaseEntry::SubWorkflow(YamlSubWorkflowRef { + workflow_ref: sub.workflow_ref.clone(), + }), WorkflowPhaseEntry::Rich(config) => { let mut map = HashMap::new(); map.insert( @@ -248,6 +271,8 @@ pub(super) fn workflow_phase_entry_to_yaml(entry: &WorkflowPhaseEntry) -> YamlPh skip_if: config.skip_if.clone(), on_verdict: config.on_verdict.clone(), budget: config.budget.clone(), + environment: config.environment.clone(), + workspace: config.workspace.clone(), }, ); YamlPhaseEntry::Rich(map) @@ -255,50 +280,66 @@ pub(super) fn workflow_phase_entry_to_yaml(entry: &WorkflowPhaseEntry) -> YamlPh } } -pub(crate) fn workflow_definition_to_yaml(definition: &WorkflowDefinition) -> YamlWorkflowDefinition { +pub(crate) fn workflow_definition_to_yaml( + definition: &WorkflowDefinition, +) -> YamlWorkflowDefinition { YamlWorkflowDefinition { id: definition.id.clone(), name: Some(definition.name.clone()), description: Some(definition.description.clone()), - phases: definition.phases.iter().map(workflow_phase_entry_to_yaml).collect(), + phases: definition + .phases + .iter() + .map(workflow_phase_entry_to_yaml) + .collect(), variables: definition.variables.clone(), worktree: definition.worktree.clone().map(YamlPhaseWorktree::Full), budget: definition.budget.clone(), + environment: definition.environment.clone(), + workspace: definition.workspace.clone(), } } -pub(crate) fn phase_execution_definition_to_yaml(definition: &PhaseExecutionDefinition) -> YamlPhaseDefinition { +pub(crate) fn phase_execution_definition_to_yaml( + definition: &PhaseExecutionDefinition, +) -> YamlPhaseDefinition { YamlPhaseDefinition { mode: definition.mode.clone(), agent: definition.agent_id.clone(), - command: definition.command.clone().map(|command| YamlCommandDefinition { - program: command.program, - args: command.args, - env: command.env, - cwd_mode: Some(match command.cwd_mode { - CommandCwdMode::ProjectRoot => "project_root".to_string(), - CommandCwdMode::TaskRoot => "task_root".to_string(), - CommandCwdMode::Path => "path".to_string(), + command: definition + .command + .clone() + .map(|command| YamlCommandDefinition { + program: command.program, + args: command.args, + env: command.env, + cwd_mode: Some(match command.cwd_mode { + CommandCwdMode::ProjectRoot => "project_root".to_string(), + CommandCwdMode::TaskRoot => "task_root".to_string(), + CommandCwdMode::Path => "path".to_string(), + }), + cwd_path: command.cwd_path, + timeout_secs: command.timeout_secs, + success_exit_codes: Some(command.success_exit_codes), + parse_json_output: Some(command.parse_json_output), + expected_result_kind: command.expected_result_kind, + expected_schema: command.expected_schema, + category: command.category, + failure_pattern: command.failure_pattern, + excerpt_max_chars: command.excerpt_max_chars, + on_success_verdict: command.on_success_verdict, + on_failure_verdict: command.on_failure_verdict, + confidence: command.confidence, + failure_risk: command.failure_risk, + }), + manual: definition + .manual + .clone() + .map(|manual| YamlManualDefinition { + instructions: manual.instructions, + approval_note_required: Some(manual.approval_note_required), + timeout_secs: manual.timeout_secs, }), - cwd_path: command.cwd_path, - timeout_secs: command.timeout_secs, - success_exit_codes: Some(command.success_exit_codes), - parse_json_output: Some(command.parse_json_output), - expected_result_kind: command.expected_result_kind, - expected_schema: command.expected_schema, - category: command.category, - failure_pattern: command.failure_pattern, - excerpt_max_chars: command.excerpt_max_chars, - on_success_verdict: command.on_success_verdict, - on_failure_verdict: command.on_failure_verdict, - confidence: command.confidence, - failure_risk: command.failure_risk, - }), - manual: definition.manual.clone().map(|manual| YamlManualDefinition { - instructions: manual.instructions, - approval_note_required: Some(manual.approval_note_required), - timeout_secs: manual.timeout_secs, - }), directive: definition.directive.clone(), system_prompt: definition.system_prompt.clone(), skills: definition.skills.clone(), @@ -318,8 +359,16 @@ pub(crate) fn phase_execution_definition_to_yaml(definition: &PhaseExecutionDefi pub(crate) fn workflow_config_to_yaml_file(config: &WorkflowConfig) -> YamlWorkflowFile { YamlWorkflowFile { default_workflow_ref: Some(config.default_workflow_ref.clone()), - phase_catalog: if config.phase_catalog.is_empty() { None } else { Some(config.phase_catalog.clone()) }, - workflows: config.workflows.iter().map(workflow_definition_to_yaml).collect(), + phase_catalog: if config.phase_catalog.is_empty() { + None + } else { + Some(config.phase_catalog.clone()) + }, + workflows: config + .workflows + .iter() + .map(workflow_definition_to_yaml) + .collect(), phases: config .phase_definitions .iter() @@ -337,18 +386,25 @@ pub(crate) fn workflow_config_to_yaml_file(config: &WorkflowConfig) -> YamlWorkf triggers: config.triggers.clone(), daemon: config.daemon.clone(), secrets: config.secrets.clone(), + workspaces: config.workspaces.clone(), + environment_routing: config.environment_routing.clone(), } } -pub(super) fn yaml_phase_entry_to_workflow_phase_entry(entry: YamlPhaseEntry) -> Result { +pub(super) fn yaml_phase_entry_to_workflow_phase_entry( + entry: YamlPhaseEntry, +) -> Result { match entry { YamlPhaseEntry::Simple(id) => Ok(WorkflowPhaseEntry::Simple(id)), - YamlPhaseEntry::SubWorkflow(sub) => { - Ok(WorkflowPhaseEntry::SubWorkflow(SubWorkflowRef { workflow_ref: sub.workflow_ref })) - } + YamlPhaseEntry::SubWorkflow(sub) => Ok(WorkflowPhaseEntry::SubWorkflow(SubWorkflowRef { + workflow_ref: sub.workflow_ref, + })), YamlPhaseEntry::Rich(map) => { if map.len() != 1 { - return Err(anyhow!("rich phase entry must have exactly one key (the phase id), got {}", map.len())); + return Err(anyhow!( + "rich phase entry must have exactly one key (the phase id), got {}", + map.len() + )); } let (id, config) = map.into_iter().next().unwrap(); Ok(WorkflowPhaseEntry::Rich(WorkflowPhaseConfig { @@ -357,13 +413,21 @@ pub(super) fn yaml_phase_entry_to_workflow_phase_entry(entry: YamlPhaseEntry) -> on_verdict: config.on_verdict, skip_if: config.skip_if, budget: config.budget, + environment: config.environment, + workspace: config.workspace, })) } } } -pub(super) fn yaml_workflow_to_workflow_definition(yaml: YamlWorkflowDefinition) -> Result { - let phases = yaml.phases.into_iter().map(yaml_phase_entry_to_workflow_phase_entry).collect::>>()?; +pub(super) fn yaml_workflow_to_workflow_definition( + yaml: YamlWorkflowDefinition, +) -> Result { + let phases = yaml + .phases + .into_iter() + .map(yaml_phase_entry_to_workflow_phase_entry) + .collect::>>()?; let worktree = yaml.worktree.map(WorktreeConfig::from_yaml).transpose()?; Ok(WorkflowDefinition { id: yaml.id.clone(), @@ -373,11 +437,15 @@ pub(super) fn yaml_workflow_to_workflow_definition(yaml: YamlWorkflowDefinition) variables: yaml.variables, worktree, budget: yaml.budget, + environment: yaml.environment, + workspace: yaml.workspace, }) } fn source_label(source_path: Option<&Path>) -> String { - source_path.map(|p| p.display().to_string()).unwrap_or_else(|| "".to_string()) + source_path + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "".to_string()) } fn reject_removed_post_success_merge(yaml_str: &str, source_path: Option<&Path>) -> Result<()> { @@ -386,9 +454,16 @@ fn reject_removed_post_success_merge(yaml_str: &str, source_path: Option<&Path>) }; let mut post_success_merge_found = false; - if let Some(workflows) = doc.get("workflows").and_then(serde_yaml::Value::as_sequence) { + if let Some(workflows) = doc + .get("workflows") + .and_then(serde_yaml::Value::as_sequence) + { for workflow in workflows { - if workflow.get("post_success").and_then(|ps| ps.get("merge")).is_some() { + if workflow + .get("post_success") + .and_then(|ps| ps.get("merge")) + .is_some() + { post_success_merge_found = true; break; } @@ -400,7 +475,12 @@ fn reject_removed_post_success_merge(yaml_str: &str, source_path: Option<&Path>) .lines() .enumerate() .find(|(_, line)| line.trim_start().starts_with("merge:")) - .or_else(|| yaml_str.lines().enumerate().find(|(_, line)| line.trim_start().starts_with("post_success:"))) + .or_else(|| { + yaml_str + .lines() + .enumerate() + .find(|(_, line)| line.trim_start().starts_with("post_success:")) + }) .map(|(idx, _)| format!("{}:{}", source_label(source_path), idx + 1)) .unwrap_or_else(|| source_label(source_path)); @@ -509,7 +589,12 @@ pub fn resolve_agent_system_prompt_files_confined_to_pack( source_path: &Path, pack_root: &Path, ) -> Result<()> { - resolve_agent_system_prompt_files_internal(agent_profiles, yaml_str, Some(source_path), Some(pack_root)) + resolve_agent_system_prompt_files_internal( + agent_profiles, + yaml_str, + Some(source_path), + Some(pack_root), + ) } fn resolve_agent_system_prompt_files_internal( @@ -543,7 +628,10 @@ fn resolve_agent_system_prompt_files_internal( )); } - let inline_set = profile.system_prompt.as_deref().is_some_and(|prompt| !prompt.trim().is_empty()); + let inline_set = profile + .system_prompt + .as_deref() + .is_some_and(|prompt| !prompt.trim().is_empty()); if inline_set { let line = find_field_line_in_agent(yaml_str, agent_id, "system_prompt_file"); let line_suffix = line.map(|l| format!(" line {}", l)).unwrap_or_default(); @@ -565,7 +653,10 @@ fn resolve_agent_system_prompt_files_internal( raw_path, )); } - if candidate.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if candidate + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(anyhow!( "pack workflow at {} agent '{}' system_prompt_file '{}' must not contain '..' segments", source_label(source_path), @@ -646,7 +737,10 @@ fn resolve_agent_system_prompt_files_internal( Ok(()) } -pub fn parse_yaml_workflow_config_with_base(yaml_str: &str, base: &WorkflowConfig) -> Result { +pub fn parse_yaml_workflow_config_with_base( + yaml_str: &str, + base: &WorkflowConfig, +) -> Result { parse_yaml_workflow_config_with_base_and_source(yaml_str, base, None) } @@ -671,7 +765,14 @@ pub(crate) fn parse_yaml_workflow_config_with_base_source_and_original( original: &str, resolved_secrets: &BTreeMap, ) -> Result { - parse_yaml_workflow_config_internal(yaml_str, base, source_path, None, Some(original), resolved_secrets) + parse_yaml_workflow_config_internal( + yaml_str, + base, + source_path, + None, + Some(original), + resolved_secrets, + ) } pub(crate) fn parse_yaml_workflow_config_confined_to_pack( @@ -718,6 +819,10 @@ const KNOWN_FIELD_KEYS: &[&str] = &[ "triggers", "daemon", "secrets", + "workspaces", + "environment_routing", + "environment", + "workspace", "workflow_ref", "mode", "agent", @@ -759,7 +864,9 @@ fn enrich_diagnostic( // (`- build`). Re-anchor the diagnostic on the line that actually // declares `:` so the caret points at the broken entry. if let Some(start_line) = diag.line { - if let Some((line, col_start, col_end)) = locate_field_line(yaml_str, &field, start_line) { + if let Some((line, col_start, col_end)) = + locate_field_line(yaml_str, &field, start_line) + { diag.line = Some(line); diag.col = Some(col_start); diag.excerpt = None; @@ -777,7 +884,9 @@ fn enrich_diagnostic( diag.suggestion = Some(s); } if let Some(start_line) = diag.line { - if let Some((line, col_start, col_end)) = locate_field_line(yaml_str, "worktree", start_line) { + if let Some((line, col_start, col_end)) = + locate_field_line(yaml_str, "worktree", start_line) + { diag.line = Some(line); diag.col = Some(col_start); diag.excerpt = None; @@ -808,7 +917,11 @@ fn enrich_diagnostic( /// Search `yaml_str` starting from `start_line` (1-based) for a line whose /// trimmed prefix is `:`. Returns the 1-based line plus 1-based /// column start / column end (exclusive) covering `: `. -fn locate_field_line(yaml_str: &str, field: &str, start_line: usize) -> Option<(usize, usize, usize)> { +fn locate_field_line( + yaml_str: &str, + field: &str, + start_line: usize, +) -> Option<(usize, usize, usize)> { let key = format!("{}:", field); let lines: Vec<&str> = yaml_str.lines().collect(); let start_idx = start_line.saturating_sub(1).min(lines.len()); @@ -844,14 +957,18 @@ fn locate_field_line(yaml_str: &str, field: &str, start_line: usize) -> Option<( } fn parse_unknown_field_name(msg: &str) -> Option { - let needle = msg.find("unknown field `").map(|i| i + "unknown field `".len())?; + let needle = msg + .find("unknown field `") + .map(|i| i + "unknown field `".len())?; let rest = &msg[needle..]; let end = rest.find('`')?; Some(rest[..end].to_string()) } fn parse_did_you_mean_from_message(msg: &str) -> Option { - let i = msg.find("did you mean `").map(|i| i + "did you mean `".len())?; + let i = msg + .find("did you mean `") + .map(|i| i + "did you mean `".len())?; let rest = &msg[i..]; let end = rest.find('`')?; Some(rest[..end].to_string()) @@ -899,11 +1016,17 @@ fn rebuild_excerpt_from_original( /// skipped: they are too likely to occur incidentally in unrelated /// diagnostic text (line numbers, short words), which would mangle the /// message without meaningfully protecting the secret. -fn redact_resolved_secret_values(message: &str, resolved_secrets: &BTreeMap) -> String { +fn redact_resolved_secret_values( + message: &str, + resolved_secrets: &BTreeMap, +) -> String { let mut redacted = message.to_string(); // Longest value first so an overlapping shorter secret cannot split a // longer one and leave its tail in the diagnostic. - let mut entries: Vec<(&String, &String)> = resolved_secrets.iter().filter(|(value, _)| value.len() >= 4).collect(); + let mut entries: Vec<(&String, &String)> = resolved_secrets + .iter() + .filter(|(value, _)| value.len() >= 4) + .collect(); entries.sort_by_key(|(value, _)| std::cmp::Reverse(value.len())); for (value, name) in entries { let marker = format!("[redacted:{}]", name); @@ -928,15 +1051,17 @@ fn parse_yaml_workflow_config_internal( original: Option<&str>, resolved_secrets: &BTreeMap, ) -> Result { - parse_yaml_workflow_config_unredacted(yaml_str, base, source_path, pack_root, original).map_err(|err| { - let rendered = format!("{:#}", err); - let redacted = redact_resolved_secret_values(&rendered, resolved_secrets); - if redacted == rendered { - err - } else { - anyhow!("{}", redacted) - } - }) + parse_yaml_workflow_config_unredacted(yaml_str, base, source_path, pack_root, original).map_err( + |err| { + let rendered = format!("{:#}", err); + let redacted = redact_resolved_secret_values(&rendered, resolved_secrets); + if redacted == rendered { + err + } else { + anyhow!("{}", redacted) + } + }, + ) } fn parse_yaml_workflow_config_unredacted( @@ -951,7 +1076,8 @@ fn parse_yaml_workflow_config_unredacted( let yaml_file: YamlWorkflowFile = match serde_yaml::from_str(yaml_str) { Ok(file) => file, Err(err) => { - let mut diag = enrich_diagnostic(wrap_serde_yaml_error(&err, yaml_str, source_path), yaml_str); + let mut diag = + enrich_diagnostic(wrap_serde_yaml_error(&err, yaml_str, source_path), yaml_str); if let Some(original) = original.filter(|original| *original != yaml_str) { diag = rebuild_excerpt_from_original(diag, original); } @@ -964,8 +1090,11 @@ fn parse_yaml_workflow_config_unredacted( } }; - let workflows = - yaml_file.workflows.into_iter().map(yaml_workflow_to_workflow_definition).collect::>>()?; + let workflows = yaml_file + .workflows + .into_iter() + .map(yaml_workflow_to_workflow_definition) + .collect::>>()?; let mut phase_definitions = BTreeMap::new(); let mut auto_phase_catalog = BTreeMap::new(); @@ -1001,7 +1130,12 @@ fn parse_yaml_workflow_config_unredacted( // Resolve agent model references against the top-level models registry. let mut agent_profiles = yaml_file.agents; - resolve_agent_system_prompt_files_internal(&mut agent_profiles, yaml_str, source_path, pack_root)?; + resolve_agent_system_prompt_files_internal( + &mut agent_profiles, + yaml_str, + source_path, + pack_root, + )?; if !yaml_file.models.is_empty() { for profile in agent_profiles.values_mut() { resolve_agent_model_references(profile, &yaml_file.models); @@ -1027,6 +1161,8 @@ fn parse_yaml_workflow_config_unredacted( triggers: yaml_file.triggers, daemon: yaml_file.daemon, secrets: yaml_file.secrets, + workspaces: yaml_file.workspaces, + environment_routing: yaml_file.environment_routing, }; Ok(merge_yaml_into_config(base.clone(), overlay)) @@ -1057,11 +1193,17 @@ mod tests { ); registry.insert( "gpt4o".to_string(), - crate::yaml_types::ModelRegistryEntry { model: "gpt-4o".to_string(), tool: Some("oai-runner".to_string()) }, + crate::yaml_types::ModelRegistryEntry { + model: "gpt-4o".to_string(), + tool: Some("oai-runner".to_string()), + }, ); registry.insert( "o4-mini".to_string(), - crate::yaml_types::ModelRegistryEntry { model: "o4-mini".to_string(), tool: None }, + crate::yaml_types::ModelRegistryEntry { + model: "o4-mini".to_string(), + tool: None, + }, ); registry } @@ -1084,9 +1226,18 @@ mod tests { assert_eq!(profile.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(profile.tool.as_deref(), Some("claude")); - assert_eq!(profile.fallback_models.clone().unwrap_or_default(), vec!["gpt-4o"]); - assert_eq!(profile.fallback_tools.clone().unwrap_or_default(), vec!["oai-runner"]); - assert!(profile.models.is_none(), "name list should be cleared after expansion"); + assert_eq!( + profile.fallback_models.clone().unwrap_or_default(), + vec!["gpt-4o"] + ); + assert_eq!( + profile.fallback_tools.clone().unwrap_or_default(), + vec!["oai-runner"] + ); + assert!( + profile.models.is_none(), + "name list should be cleared after expansion" + ); } #[test] @@ -1103,8 +1254,16 @@ mod tests { Some(protocol::tool_for_model_id("o4-mini")), "no explicit tool in registry → derived from the model id" ); - assert!(profile.fallback_models.as_deref().unwrap_or_default().is_empty()); - assert!(profile.fallback_tools.as_deref().unwrap_or_default().is_empty()); + assert!(profile + .fallback_models + .as_deref() + .unwrap_or_default() + .is_empty()); + assert!(profile + .fallback_tools + .as_deref() + .unwrap_or_default() + .is_empty()); } #[test] @@ -1116,9 +1275,16 @@ mod tests { resolve_agent_model_references(&mut profile, ®istry); assert_eq!(profile.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert_eq!(profile.fallback_models.clone().unwrap_or_default(), vec!["deepseek-v3"]); + assert_eq!( + profile.fallback_models.clone().unwrap_or_default(), + vec!["deepseek-v3"] + ); // deepseek-v3 isn't in registry, so no explicit fallback_tool - assert!(profile.fallback_tools.as_deref().unwrap_or_default().is_empty()); + assert!(profile + .fallback_tools + .as_deref() + .unwrap_or_default() + .is_empty()); } #[test] @@ -1130,7 +1296,11 @@ mod tests { resolve_agent_model_references(&mut profile, ®istry); assert_eq!(profile.model.as_deref(), Some("existing-model")); - assert!(profile.fallback_models.as_deref().unwrap_or_default().is_empty()); + assert!(profile + .fallback_models + .as_deref() + .unwrap_or_default() + .is_empty()); } #[test] @@ -1143,19 +1313,30 @@ mod tests { resolve_agent_model_references(&mut profile, ®istry); assert_eq!(profile.model.as_deref(), Some("hardcoded-model")); - assert_eq!(profile.fallback_models.clone().unwrap_or_default(), vec!["hardcoded-fallback"]); + assert_eq!( + profile.fallback_models.clone().unwrap_or_default(), + vec!["hardcoded-fallback"] + ); } #[test] fn model_registry_skips_empty_name_entries() { let registry = make_test_registry(); let mut profile = make_empty_profile(); - profile.models = Some(vec!["".to_string(), "claude-opus".to_string(), " ".to_string()]); + profile.models = Some(vec![ + "".to_string(), + "claude-opus".to_string(), + " ".to_string(), + ]); resolve_agent_model_references(&mut profile, ®istry); assert_eq!(profile.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert!(profile.fallback_models.as_deref().unwrap_or_default().is_empty()); + assert!(profile + .fallback_models + .as_deref() + .unwrap_or_default() + .is_empty()); } #[test] @@ -1197,11 +1378,20 @@ phases: directive: "Implement." "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let swe = config.agent_profiles.get("swe").expect("swe agent should exist"); + let swe = config + .agent_profiles + .get("swe") + .expect("swe agent should exist"); assert_eq!(swe.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(swe.tool.as_deref(), Some("claude")); - assert_eq!(swe.fallback_models.clone().unwrap_or_default(), vec!["gpt-4o"]); - assert_eq!(swe.fallback_tools.clone().unwrap_or_default(), vec!["oai-runner"]); + assert_eq!( + swe.fallback_models.clone().unwrap_or_default(), + vec!["gpt-4o"] + ); + assert_eq!( + swe.fallback_tools.clone().unwrap_or_default(), + vec!["oai-runner"] + ); } #[test] @@ -1239,7 +1429,10 @@ workflows: phases: [design] "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let architect = config.agent_profiles.get("architect").expect("architect agent"); + let architect = config + .agent_profiles + .get("architect") + .expect("architect agent"); assert_eq!(architect.name.as_deref(), Some("Mira")); assert!(architect.memory.clone().unwrap_or_default().enabled); assert!(architect.communication.clone().unwrap_or_default().enabled); @@ -1264,9 +1457,15 @@ phases: let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); let swe = config.agent_profiles.get("swe").expect("swe agent"); let policy = swe.approval_policy.clone().expect("approval policy"); - assert_eq!(policy.auto_allow, vec!["git.*".to_string(), "cargo *".to_string()]); + assert_eq!( + policy.auto_allow, + vec!["git.*".to_string(), "cargo *".to_string()] + ); assert_eq!(policy.auto_deny, vec!["*force*".to_string()]); - assert_eq!(policy.default, crate::agent_types::ApprovalPolicyDefault::Ask); + assert_eq!( + policy.default, + crate::agent_types::ApprovalPolicyDefault::Ask + ); } #[test] @@ -1290,10 +1489,19 @@ phases: directive: "Implement." "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let swe = config.agent_profiles.get("swe").expect("swe agent should exist"); + let swe = config + .agent_profiles + .get("swe") + .expect("swe agent should exist"); assert_eq!(swe.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert_eq!(swe.fallback_models.clone().unwrap_or_default(), vec!["gpt-4o", "o4-mini"]); - assert_eq!(swe.fallback_tools.clone().unwrap_or_default(), vec!["oai-runner"]); + assert_eq!( + swe.fallback_models.clone().unwrap_or_default(), + vec!["gpt-4o", "o4-mini"] + ); + assert_eq!( + swe.fallback_tools.clone().unwrap_or_default(), + vec!["oai-runner"] + ); } #[test] @@ -1318,7 +1526,10 @@ phases: - oai-runner "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let impl_phase = config.phase_definitions.get("impl").expect("impl phase should exist"); + let impl_phase = config + .phase_definitions + .get("impl") + .expect("impl phase should exist"); let runtime = impl_phase.runtime.as_ref().expect("runtime should exist"); assert_eq!(runtime.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(runtime.fallback_models, vec!["gpt-4o", "o4-mini"]); @@ -1354,12 +1565,21 @@ phases: directive: "Implement." "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let swe = config.agent_profiles.get("swe").expect("swe agent should exist"); + let swe = config + .agent_profiles + .get("swe") + .expect("swe agent should exist"); assert_eq!(swe.model.as_deref(), Some("claude-sonnet-4-20250514")); assert_eq!(swe.tool.as_deref(), Some("claude")); - assert_eq!(swe.fallback_models.clone().unwrap_or_default(), vec!["gpt-4o", "o4-mini"]); + assert_eq!( + swe.fallback_models.clone().unwrap_or_default(), + vec!["gpt-4o", "o4-mini"] + ); // Only secondary has explicit tool; tertiary has none → only "oai-runner" in fallback_tools - assert_eq!(swe.fallback_tools.clone().unwrap_or_default(), vec!["oai-runner"]); + assert_eq!( + swe.fallback_tools.clone().unwrap_or_default(), + vec!["oai-runner"] + ); } #[test] @@ -1377,9 +1597,16 @@ phases: directive: "Implement." "#; let config = parse_yaml_workflow_config(yaml).expect("parse yaml"); - let swe = config.agent_profiles.get("swe").expect("swe agent should exist"); + let swe = config + .agent_profiles + .get("swe") + .expect("swe agent should exist"); assert!(swe.model.is_none()); - assert!(swe.fallback_models.as_deref().unwrap_or_default().is_empty()); + assert!(swe + .fallback_models + .as_deref() + .unwrap_or_default() + .is_empty()); } #[test] @@ -1410,7 +1637,10 @@ phases: .expect("parse yaml with source path"); let analyst = config.agent_profiles.get("analyst").expect("analyst agent"); assert_eq!(analyst.system_prompt.as_deref(), Some(prompt_body)); - assert!(analyst.system_prompt_file.is_none(), "system_prompt_file should be consumed"); + assert!( + analyst.system_prompt_file.is_none(), + "system_prompt_file should be consumed" + ); } #[test] @@ -1437,10 +1667,14 @@ phases: let yaml_path = temp.path().join("nested").join("workflows.yaml"); let base = builtin_workflow_config(); - let config = parse_yaml_workflow_config_with_base_and_source(&yaml, &base, Some(&yaml_path)) - .expect("parse yaml with absolute path"); + let config = + parse_yaml_workflow_config_with_base_and_source(&yaml, &base, Some(&yaml_path)) + .expect("parse yaml with absolute path"); let analyst = config.agent_profiles.get("analyst").expect("analyst agent"); - assert_eq!(analyst.system_prompt.as_deref(), Some("absolute prompt body")); + assert_eq!( + analyst.system_prompt.as_deref(), + Some("absolute prompt body") + ); } #[test] @@ -1470,7 +1704,10 @@ phases: let msg = format!("{:#}", err); assert!(msg.contains("analyst"), "missing agent id: {msg}"); assert!(msg.contains("system_prompt"), "missing field name: {msg}"); - assert!(msg.contains(&yaml_path.display().to_string()), "missing source path: {msg}"); + assert!( + msg.contains(&yaml_path.display().to_string()), + "missing source path: {msg}" + ); assert!(msg.contains("line"), "missing line number: {msg}"); } @@ -1495,7 +1732,10 @@ phases: .expect_err("missing file should error"); let msg = format!("{:#}", err); let resolved = temp.path().join("prompts").join("missing.md"); - assert!(msg.contains(&resolved.display().to_string()), "missing resolved path: {msg}"); + assert!( + msg.contains(&resolved.display().to_string()), + "missing resolved path: {msg}" + ); assert!(msg.contains("analyst"), "missing agent id: {msg}"); } @@ -1523,8 +1763,14 @@ phases: let err = parse_yaml_workflow_config_with_base_and_source(yaml, &base, Some(&yaml_path)) .expect_err("oversize should error"); let msg = format!("{:#}", err); - assert!(msg.contains("1 MiB") || msg.contains("maximum"), "missing size cap: {msg}"); - assert!(msg.contains(&prompt_path.display().to_string()), "missing resolved path: {msg}"); + assert!( + msg.contains("1 MiB") || msg.contains("maximum"), + "missing size cap: {msg}" + ); + assert!( + msg.contains(&prompt_path.display().to_string()), + "missing resolved path: {msg}" + ); } #[test] @@ -1550,7 +1796,10 @@ phases: let err = parse_yaml_workflow_config_with_base_and_source(yaml, &base, Some(&yaml_path)) .expect_err("non-utf8 should error"); let msg = format!("{:#}", err); - assert!(msg.contains("UTF-8") || msg.contains("utf-8"), "missing utf-8 marker: {msg}"); + assert!( + msg.contains("UTF-8") || msg.contains("utf-8"), + "missing utf-8 marker: {msg}" + ); } #[test] @@ -1576,7 +1825,15 @@ phases: let base = builtin_workflow_config(); let config = parse_yaml_workflow_config_with_base_and_source(yaml, &base, Some(&yaml_path)) .expect("parse should succeed"); - assert_eq!(config.agent_profiles.get("analyst").unwrap().system_prompt.as_deref(), Some(body)); + assert_eq!( + config + .agent_profiles + .get("analyst") + .unwrap() + .system_prompt + .as_deref(), + Some(body) + ); } #[test] @@ -1586,7 +1843,10 @@ phases: profile.system_prompt_file = Some("prompts/agent.md".to_string()); let json = serde_json::to_string(&profile).expect("serialize"); - assert!(json.contains("system_prompt_file"), "field should be present: {json}"); + assert!( + json.contains("system_prompt_file"), + "field should be present: {json}" + ); let back: AgentProfileOverlay = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.system_prompt_file.as_deref(), Some("prompts/agent.md")); } @@ -1595,7 +1855,10 @@ phases: fn agent_profile_serde_omits_system_prompt_file_when_none() { let profile = make_empty_profile(); let json = serde_json::to_string(&profile).expect("serialize"); - assert!(!json.contains("system_prompt_file"), "field should be skipped when None: {json}"); + assert!( + !json.contains("system_prompt_file"), + "field should be skipped when None: {json}" + ); } #[test] @@ -1617,7 +1880,8 @@ phases: let yaml_path = temp.path().join("workflows.yaml"); let base = builtin_workflow_config(); let with_path = - parse_yaml_workflow_config_with_base_and_source(yaml, &base, Some(&yaml_path)).expect("parse with source"); + parse_yaml_workflow_config_with_base_and_source(yaml, &base, Some(&yaml_path)) + .expect("parse with source"); assert_eq!( with_source.agent_profiles.get("swe").unwrap().system_prompt, with_path.agent_profiles.get("swe").unwrap().system_prompt diff --git a/animus-config-protocol/src/yaml_types.rs b/animus-config-protocol/src/yaml_types.rs index 1e99e8d..985aa09 100644 --- a/animus-config-protocol/src/yaml_types.rs +++ b/animus-config-protocol/src/yaml_types.rs @@ -7,8 +7,8 @@ use serde_json::Value; use crate::yaml_diagnostic::closest_match; use crate::agent_types::{ - default_eval_expected_exit, default_eval_pass_threshold, AgentProfileOverlay, EvalKind, EvalOnFail, Idempotency, - PhaseExecutionMode, + default_eval_expected_exit, default_eval_pass_threshold, AgentProfileOverlay, EvalKind, + EvalOnFail, Idempotency, PhaseExecutionMode, }; use crate::workflow_types::*; @@ -43,6 +43,10 @@ pub(super) struct YamlPhaseRichConfig { pub(super) on_verdict: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) environment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -177,7 +181,9 @@ impl<'de> Deserialize<'de> for YamlPhaseWorktree { } else { let suggestion = match lower.as_str() { "yes" | "on" | "enabled" | "enable" | "true" => Some("auto".to_string()), - "no" | "off" | "disabled" | "disable" | "false" | "none" => Some("skip".to_string()), + "no" | "off" | "disabled" | "disable" | "false" | "none" => { + Some("skip".to_string()) + } "needed" | "must" | "force" | "mandatory" => Some("required".to_string()), _ => closest_match(&lower, WORKTREE_VALID_MODES, 2).map(|s| s.to_string()), }; @@ -254,6 +260,10 @@ pub(super) struct YamlWorkflowDefinition { #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) worktree: Option, pub(super) budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) environment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) workspace: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -410,6 +420,12 @@ pub(super) struct YamlWorkflowFile { /// `${secret.}` in any YAML scalar. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub(super) secrets: BTreeMap, + /// Named repo sets (workspaces) workflows/phases reference by name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub(super) workspaces: BTreeMap, + /// Config-level environment routing (default + match rules). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) environment_routing: Option, } /// Title-case a phase id (`code-review` -> `Code Review`) for default UI labels. diff --git a/animus-control-protocol/Cargo.toml b/animus-control-protocol/Cargo.toml index 0ae2e37..2130bf6 100644 --- a/animus-control-protocol/Cargo.toml +++ b/animus-control-protocol/Cargo.toml @@ -19,7 +19,7 @@ client = ["dep:tokio", "dep:anyhow"] [dependencies] animus-plugin-protocol = { path = "../animus-plugin-protocol", version = "0.1.12" } -animus-subject-protocol = { path = "../animus-subject-protocol", version = "0.1.12" } +animus-subject-protocol = { path = "../animus-subject-protocol", version = "0.2.0" } animus-log-storage-protocol = { path = "../animus-log-storage-protocol", version = "0.1.12" } animus-trigger-protocol = { path = "../animus-trigger-protocol", version = "0.1.12" } animus-actor = { path = "../animus-actor", version = "0.1.0" } diff --git a/animus-control-protocol/src/types.rs b/animus-control-protocol/src/types.rs index 6119594..c5badc1 100644 --- a/animus-control-protocol/src/types.rs +++ b/animus-control-protocol/src/types.rs @@ -550,6 +550,9 @@ pub struct WorkflowListRequest { /// Page size. #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, + /// Restrict to runs of this workflow definition (the "type" filter). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_ref: Option, } /// Response for `workflow/list`. @@ -560,6 +563,10 @@ pub struct WorkflowListResponse { /// Next-page cursor, or `None` if exhausted. #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, + /// Total matching runs across ALL pages (for the filter), so the UI can show + /// a count + compute page numbers. `None` for backends that don't count. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, } /// One row of `workflow/list`. diff --git a/animus-environment-protocol/Cargo.toml b/animus-environment-protocol/Cargo.toml new file mode 100644 index 0000000..cf52af3 --- /dev/null +++ b/animus-environment-protocol/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "animus-environment-protocol" +version = "0.1.0" +edition = "2021" +description = "Wire types for the Animus `environment` plugin role (JSON-RPC 2.0). Prepares an execution context (worktree, container, remote host), runs provider harness commands inside it, and tears it down." + +[dependencies] +animus-plugin-protocol = { path = "../animus-plugin-protocol" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1.0" + +[dev-dependencies] +tempfile = "3" + +[[bin]] +name = "animus-environment-protocol-export-schema" +path = "src/bin/export_schema.rs" diff --git a/animus-environment-protocol/src/bin/export_schema.rs b/animus-environment-protocol/src/bin/export_schema.rs new file mode 100644 index 0000000..772237b --- /dev/null +++ b/animus-environment-protocol/src/bin/export_schema.rs @@ -0,0 +1,202 @@ +//! Export JSON Schema artifacts for every public wire type in +//! `animus-environment-protocol`. +//! +//! Usage: +//! +//! ```text +//! cargo run -p animus-environment-protocol --bin animus-environment-protocol-export-schema -- [--out ] +//! ``` +//! +//! Mirrors the binary in `animus-plugin-protocol`. The default output directory +//! is `schemas/animus-environment-protocol/` resolved relative to the workspace +//! root. One file is written per type plus an `_all.json` bundle for tooling +//! that wants a single artifact. +//! +//! The wire-level error shape is `animus_plugin_protocol::RpcError`, which is +//! exported by the sibling `animus-plugin-protocol` schema binary and so is not +//! re-emitted here. + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use animus_environment_protocol::{ + EnvironmentHandle, EnvironmentNode, EnvironmentSpec, ExecNotification, ExecRequest, + ExecResponse, ExecSessionRequest, ExecSessionResponse, ExecStream, GetNodeRequest, + GetNodeResponse, HarnessCommand, ListNodesRequest, ListNodesResponse, PrepareRequest, + PrepareResponse, ReapRequest, ReapResponse, RepoRef, TeardownNodeRequest, TeardownNodeResponse, + TeardownRequest, TeardownResponse, +}; +use schemars::{schema_for, Schema}; + +fn default_out_dir() -> PathBuf { + let base = env::var_os("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .and_then(|dir| dir.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + base.join("schemas").join("animus-environment-protocol") +} + +fn parse_out_dir(args: &[String]) -> Option { + let mut iter = args.iter().skip(1); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--out" | "-o" => { + if let Some(value) = iter.next() { + return Some(PathBuf::from(value)); + } + } + other if other.starts_with("--out=") => { + return Some(PathBuf::from(&other["--out=".len()..])); + } + _ => {} + } + } + None +} + +/// Build the list of `(TypeName, Schema)` pairs. Centralized so the smoke test +/// and the binary stay in sync. +pub fn all_schemas() -> Vec<(&'static str, Schema)> { + vec![ + ("RepoRef", schema_for!(RepoRef)), + ("EnvironmentSpec", schema_for!(EnvironmentSpec)), + ("EnvironmentHandle", schema_for!(EnvironmentHandle)), + ("PrepareRequest", schema_for!(PrepareRequest)), + ("PrepareResponse", schema_for!(PrepareResponse)), + ("HarnessCommand", schema_for!(HarnessCommand)), + ("ExecRequest", schema_for!(ExecRequest)), + ("ExecResponse", schema_for!(ExecResponse)), + ("ExecSessionRequest", schema_for!(ExecSessionRequest)), + ("ExecSessionResponse", schema_for!(ExecSessionResponse)), + ("ExecStream", schema_for!(ExecStream)), + ("ExecNotification", schema_for!(ExecNotification)), + ("TeardownRequest", schema_for!(TeardownRequest)), + ("TeardownResponse", schema_for!(TeardownResponse)), + ("EnvironmentNode", schema_for!(EnvironmentNode)), + ("ListNodesRequest", schema_for!(ListNodesRequest)), + ("ListNodesResponse", schema_for!(ListNodesResponse)), + ("GetNodeRequest", schema_for!(GetNodeRequest)), + ("GetNodeResponse", schema_for!(GetNodeResponse)), + ("TeardownNodeRequest", schema_for!(TeardownNodeRequest)), + ("TeardownNodeResponse", schema_for!(TeardownNodeResponse)), + ("ReapRequest", schema_for!(ReapRequest)), + ("ReapResponse", schema_for!(ReapResponse)), + ] +} + +/// Write every type's schema to `out_dir` and emit a combined `_all.json` +/// bundle. Used by the binary and the smoke test. +/// +/// The bundle places every type — and every nested `$defs` entry — under a +/// single top-level `$defs`, so `#/$defs/` references inside the bundled +/// schemas resolve against the bundle root. +pub fn export_to(out_dir: &Path) -> std::io::Result { + fs::create_dir_all(out_dir)?; + let schemas = all_schemas(); + let mut defs: BTreeMap = BTreeMap::new(); + for (name, schema) in &schemas { + let path = out_dir.join(format!("{name}.json")); + let pretty = serde_json::to_string_pretty(schema).expect("schema serializes to JSON"); + fs::write(&path, format!("{pretty}\n"))?; + + let mut as_value = serde_json::to_value(schema).expect("schema serializes to JSON value"); + if let Some(obj) = as_value.as_object_mut() { + if let Some(serde_json::Value::Object(inner)) = obj.remove("$defs") { + for (k, v) in inner { + defs.entry(k).or_insert(v); + } + } + obj.remove("$schema"); + } + defs.insert((*name).to_string(), as_value); + } + + let bundle = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "animus-environment-protocol", + "$defs": defs, + }); + let bundle_path = out_dir.join("_all.json"); + let bundle_pretty = serde_json::to_string_pretty(&bundle).expect("bundle serializes to JSON"); + fs::write(&bundle_path, format!("{bundle_pretty}\n"))?; + Ok(schemas.len()) +} + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + let out_dir = parse_out_dir(&args).unwrap_or_else(default_out_dir); + match export_to(&out_dir) { + Ok(count) => { + println!("wrote {count} schemas + _all.json to {}", out_dir.display()); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("export-schema: {err}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn export_writes_one_file_per_type_and_bundle() { + let tmp = tempfile::tempdir().expect("tempdir"); + let count = export_to(tmp.path()).expect("export ok"); + assert!(count > 0); + + for (name, _) in all_schemas() { + let path = tmp.path().join(format!("{name}.json")); + let raw = std::fs::read_to_string(&path).expect("schema file readable"); + let value: Value = serde_json::from_str(&raw).expect("schema file parses"); + assert!(value.is_object(), "{name} schema should be a JSON object"); + assert!( + value.get("$schema").is_some(), + "{name} schema should include $schema" + ); + assert!( + value.get("title").is_some(), + "{name} schema should include title" + ); + } + + let bundle_raw = + std::fs::read_to_string(tmp.path().join("_all.json")).expect("bundle readable"); + let bundle: Value = serde_json::from_str(&bundle_raw).expect("bundle parses"); + let defs = bundle + .get("$defs") + .and_then(|d| d.as_object()) + .expect("bundle has $defs"); + for (name, _) in all_schemas() { + assert!( + defs.contains_key(name), + "bundle $defs should contain {name}" + ); + } + assert!( + bundle.get("$schema").is_some(), + "bundle should advertise $schema" + ); + } + + #[test] + fn environment_spec_emits_object_type() { + let schema = schema_for!(EnvironmentSpec); + let value = serde_json::to_value(&schema).expect("serializes"); + let type_field = value.get("type").expect("schema has a type field").clone(); + assert!( + type_field == Value::String("object".to_string()) + || type_field + .as_array() + .map(|arr| arr.iter().any(|v| v == "object")) + .unwrap_or(false), + "EnvironmentSpec schema should report object type, got {type_field}" + ); + } +} diff --git a/animus-environment-protocol/src/lib.rs b/animus-environment-protocol/src/lib.rs new file mode 100644 index 0000000..6256155 --- /dev/null +++ b/animus-environment-protocol/src/lib.rs @@ -0,0 +1,701 @@ +//! Wire types for the Animus `environment` plugin role (v0.7). +//! +//! An *environment plugin* owns the execution context a provider harness runs +//! inside. The three flagship implementations are a git-worktree environment +//! (local, the default), a container environment (Docker / OCI), and a remote +//! environment (a Railway runner, an SSH host, a cloud sandbox). All three +//! speak the same three-call contract: +//! +//! 1. [`METHOD_ENVIRONMENT_PREPARE`] — materialize the context (check out the +//! repo set, spin up the container, provision the remote host) and return an +//! [`EnvironmentHandle`]. +//! 2. [`METHOD_ENVIRONMENT_EXEC`] — run a [`HarnessCommand`] inside the prepared +//! context and return its buffered [`ExecResponse`]. A streaming variant, +//! [`METHOD_ENVIRONMENT_EXEC_STREAM`], emits incremental +//! [`NOTIFICATION_ENVIRONMENT_OUTPUT`] notifications for stdout/stderr as they +//! are produced, then returns the same [`ExecResponse`] as the final reply. +//! 3. [`METHOD_ENVIRONMENT_TEARDOWN`] — dispose of the context (prune the +//! worktree, stop + remove the container, release the remote host). +//! +//! Like every Animus plugin, environment plugins speak newline-delimited +//! JSON-RPC 2.0 over stdio (see `animus-plugin-protocol`). This crate defines +//! only the language-neutral request/response/notification shapes and the +//! method-name constants; it deliberately does not define a Rust trait or the +//! stdio loop (those live in `animus-plugin-runtime` and can be layered on +//! later). +//! +//! # Exec streaming +//! +//! The exec surface follows the proven server-streaming pattern used by +//! `animus-provider-protocol`'s `agent/run`: a plugin that supports streaming +//! emits [`ExecNotification`]s (wrapped by the runtime into +//! [`NOTIFICATION_ENVIRONMENT_OUTPUT`] JSON-RPC notifications) on the same +//! channel as the eventual [`ExecResponse`] reply. The buffered +//! [`METHOD_ENVIRONMENT_EXEC`] call is the baseline every environment plugin +//! MUST implement; [`METHOD_ENVIRONMENT_EXEC_STREAM`] is the opt-in streaming +//! upgrade, and a plugin that does not implement it responds with +//! [`animus_plugin_protocol::error_codes::METHOD_NOT_SUPPORTED`]. +//! +//! Streaming is currently one-directional (plugin → host: stdout/stderr). +//! Buffered stdin is carried up-front on [`ExecRequest::stdin`]. Live, +//! interactive stdin over the lifetime of a streamed exec is deferred — see the +//! TODO on [`ExecRequest::stdin`]; when added it will mirror +//! `animus-provider-protocol`'s host → plugin `agent/respond` request rather +//! than a notification. + +#![warn(missing_docs)] + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ===================================================================== +// Method-name constants (the JSON-RPC wire methods) +// ===================================================================== + +/// `environment/prepare` — materialize an execution context from an +/// [`EnvironmentSpec`] and return an [`EnvironmentHandle`]. +pub const METHOD_ENVIRONMENT_PREPARE: &str = "environment/prepare"; + +/// `environment/exec` — run a [`HarnessCommand`] inside a prepared context and +/// return its buffered [`ExecResponse`]. This is the baseline exec call every +/// environment plugin implements. +pub const METHOD_ENVIRONMENT_EXEC: &str = "environment/exec"; + +/// `environment/exec_stream` — like [`METHOD_ENVIRONMENT_EXEC`], but the plugin +/// emits incremental [`NOTIFICATION_ENVIRONMENT_OUTPUT`] notifications for +/// stdout/stderr as they are produced and then returns the aggregated +/// [`ExecResponse`] as the final reply. Optional; plugins that do not implement +/// streaming respond with +/// [`animus_plugin_protocol::error_codes::METHOD_NOT_SUPPORTED`]. +pub const METHOD_ENVIRONMENT_EXEC_STREAM: &str = "environment/exec_stream"; + +/// `environment/exec_session` — dispatch a SUBJECT to the environment's own +/// animus (REQ-052 remote-animus): the plugin hands the subject to the node's +/// in-container animus, which runs the workflow through its own provider/session +/// layer and streams rich [`NOTIFICATION_ENVIRONMENT_JOURNAL`] events back; +/// the final reply is an [`ExecSessionResponse`]. Optional; plugins that do not +/// run a remote animus respond with +/// [`animus_plugin_protocol::error_codes::METHOD_NOT_SUPPORTED`]. +pub const METHOD_ENVIRONMENT_EXEC_SESSION: &str = "environment/exec_session"; + +/// `environment/teardown` — dispose of a prepared context by handle. +pub const METHOD_ENVIRONMENT_TEARDOWN: &str = "environment/teardown"; + +/// `environment/list` — list every managed node (instance) the plugin owns, +/// each an [`EnvironmentNode`]. Part of the node-management surface, which lets +/// an operator inspect + reap environment instances uniformly across substrates. +pub const METHOD_ENVIRONMENT_LIST: &str = "environment/list"; + +/// `environment/get` — describe one managed node by substrate id or name. +pub const METHOD_ENVIRONMENT_GET: &str = "environment/get"; + +/// `environment/teardown_node` — destroy ONE managed node by substrate id or +/// name. Distinct from [`METHOD_ENVIRONMENT_TEARDOWN`], which disposes a +/// prepared context by its [`EnvironmentHandle`]. +pub const METHOD_ENVIRONMENT_TEARDOWN_NODE: &str = "environment/teardown_node"; + +/// `environment/reap` — destroy orphaned/dead managed nodes (see +/// [`ReapRequest`]). The cleanup that keeps leaked instances from accumulating. +pub const METHOD_ENVIRONMENT_REAP: &str = "environment/reap"; + +/// `environment/output` — server-streaming notification carrying an +/// [`ExecNotification`] for an in-flight [`METHOD_ENVIRONMENT_EXEC_STREAM`] +/// call. +pub const NOTIFICATION_ENVIRONMENT_OUTPUT: &str = "environment/output"; + +/// `environment/journal` — server-streaming notification carrying a journal +/// event from an in-flight [`METHOD_ENVIRONMENT_EXEC_SESSION`] call (the node's +/// own workflow journal, forwarded verbatim). +pub const NOTIFICATION_ENVIRONMENT_JOURNAL: &str = "environment/journal"; + +// ===================================================================== +// Repo set / workspace +// ===================================================================== + +/// A single repository in an environment's workspace (repo set). +/// +/// A multi-repo workspace (see the top-level `workspace:` config) checks out +/// more than one `RepoRef` under [`EnvironmentHandle::workspace_root`], each in +/// its own subdirectory named by [`RepoRef::name`] (or derived from +/// [`RepoRef::url`] when `name` is unset). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct RepoRef { + /// Clone URL or local path for the repository. The originating environment + /// plugin interprets the value (an `https://`/`git@` remote for the + /// worktree/container/remote runners, or a local path for a bind-mount). + pub url: String, + + /// Subdirectory name to check the repo out under, relative to + /// [`EnvironmentHandle::workspace_root`]. When unset, the plugin derives it + /// from the last path segment of [`Self::url`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Git ref (branch, tag, or commit) to check out. When unset, the plugin + /// uses the remote's default branch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_ref: Option, + + /// True when this repo is the primary workspace repo (the one a + /// single-repo subject maps to, and the default `cwd` for + /// [`HarnessCommand`]). At most one repo in a set should be primary; when + /// none is marked, the first entry is primary. + #[serde(default, skip_serializing_if = "is_false")] + pub primary: bool, +} + +// ===================================================================== +// Prepare +// ===================================================================== + +/// Declarative description of the execution context to materialize. +/// +/// [`Self::kind`] names the environment plugin id (e.g. `"worktree"`, +/// `"container"`, `"railway"`) so the kernel can route a `prepare` call to the +/// right plugin. The rest of the spec is intentionally open: [`Self::image`], +/// [`Self::resources`], and [`Self::metadata`] carry plugin-specific knobs that +/// the kernel passes through opaquely. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentSpec { + /// Environment plugin id that should service this spec (the plugin's + /// declared environment kind, not [`animus_plugin_protocol::PluginKind`]). + pub kind: String, + + /// The repo set / workspace to materialize. May be empty for a + /// repo-less environment (e.g. a scratch container). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repos: Vec, + + /// Container/VM image reference for image-based environments (Docker tag, + /// OCI ref, AMI id, ...). Ignored by the worktree environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + + /// Resource requests/limits for the environment (cpu, memory, disk, + /// timeout, region, ...). Shape is plugin-defined; carried opaquely. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + + /// Environment variables to inject into every command run in this context. + /// Non-secret config only — secrets flow through the kernel's secret store, + /// not this field. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + + /// Free-form plugin-specific metadata (labels, base_ref, network mode, + /// mounts, ...). Carried opaquely by the kernel. + #[serde(default, skip_serializing_if = "Value::is_null")] + pub metadata: Value, +} + +/// Request payload for [`METHOD_ENVIRONMENT_PREPARE`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PrepareRequest { + /// The environment to materialize. + pub spec: EnvironmentSpec, +} + +/// Response payload for [`METHOD_ENVIRONMENT_PREPARE`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PrepareResponse { + /// Handle to the materialized context, used for subsequent `exec` and + /// `teardown` calls. + pub handle: EnvironmentHandle, +} + +/// Handle to a prepared execution context. +/// +/// The kernel treats [`Self::id`] as opaque and passes the whole handle back on +/// every [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin +/// interprets the id. [`Self::workspace_root`] is the absolute path (on the +/// plugin's side of the world) that command `cwd`s resolve against. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentHandle { + /// Opaque, plugin-assigned identifier for this prepared context. + pub id: String, + + /// Absolute path to the root of the materialized workspace. Command `cwd`s + /// ([`HarnessCommand::cwd`]) resolve relative to this path; for a + /// multi-repo workspace each [`RepoRef`] lives in a subdirectory under it. + pub workspace_root: String, + + /// Free-form plugin-specific metadata about the prepared context + /// (container id, remote host, allocated ports, ...). Carried opaquely. + #[serde(default, skip_serializing_if = "Value::is_null")] + pub metadata: Value, +} + +// ===================================================================== +// Exec +// ===================================================================== + +/// A command to run inside a prepared environment. +/// +/// This is the harness invocation the provider layer would otherwise run +/// directly on the host; the environment plugin runs it inside the prepared +/// context instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HarnessCommand { + /// Executable to run (looked up on the environment's `PATH` unless + /// absolute). + pub program: String, + + /// Arguments passed to [`Self::program`], not including `argv[0]`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + + /// Extra environment variables for this command, merged over (and + /// overriding) [`EnvironmentSpec::env`]. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + + /// Working directory for the command, relative to + /// [`EnvironmentHandle::workspace_root`]. When unset, runs in the primary + /// repo's directory (or the workspace root when there is no primary repo). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +/// Request payload for [`METHOD_ENVIRONMENT_EXEC`] and +/// [`METHOD_ENVIRONMENT_EXEC_STREAM`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExecRequest { + /// The prepared context to run in. + pub handle: EnvironmentHandle, + + /// The command to run. + pub command: HarnessCommand, + + /// Bytes to feed to the command's stdin, up front, as a UTF-8 string. + /// + // TODO(v0.7): live, interactive stdin over the lifetime of a streamed exec + // is not yet modeled. When added it will mirror + // `animus-provider-protocol`'s host → plugin `agent/respond` request + // (a correlated JSON-RPC call), not a fire-and-forget notification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdin: Option, + + /// Hard wall-clock timeout in seconds. When exceeded the environment kills + /// the command and returns [`ExecResponse::timed_out`] = true. `None` means + /// no explicit timeout (the environment may still impose its own). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, +} + +/// Response payload for [`METHOD_ENVIRONMENT_EXEC`] / +/// [`METHOD_ENVIRONMENT_EXEC_STREAM`]. +/// +/// For [`METHOD_ENVIRONMENT_EXEC_STREAM`], [`Self::stdout`] / [`Self::stderr`] +/// carry the aggregated output already delivered incrementally via +/// [`ExecNotification`]s; a client that consumed the stream can ignore them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExecResponse { + /// Process exit code. `None` when the process was terminated by a signal or + /// killed on timeout without producing an exit code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + + /// Aggregated stdout captured from the command. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stdout: String, + + /// Aggregated stderr captured from the command. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stderr: String, + + /// True when the command was killed because it exceeded + /// [`ExecRequest::timeout_secs`]. + #[serde(default, skip_serializing_if = "is_false")] + pub timed_out: bool, +} + +/// Request payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`] — dispatch a subject +/// to the environment's own animus (REQ-052 remote-animus). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExecSessionRequest { + /// The prepared context (whose in-container animus runs the subject). + pub handle: EnvironmentHandle, + + /// The subject to dispatch, qualified `kind:id` (e.g. `task:TASK-1`). + pub subject_id: String, + + /// Workflow to run the subject through. `None` uses the node's default + /// routing for the subject's kind. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_ref: Option, + + /// Optional dispatch input forwarded to the node's run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dispatch_input: Option, + + /// The DELEGATING run's workflow id (REQ-052 one-id). When set, the node + /// MUST execute INTO this already-bootstrapped run (resume-existing) rather + /// than minting its own, so exactly ONE journal row exists for the dispatch + /// and the node's transcript lands on the id the portal reads. `None` keeps + /// the legacy behavior (the node mints its own run id). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_id: Option, +} + +/// Response payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`]: the node-local run +/// id the dispatch spawned and its terminal status. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ExecSessionResponse { + /// The node-local workflow run id, when one was spawned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow_id: Option, + + /// Terminal status of the node-local run (e.g. `completed`, `failed`, + /// `escalated`, `cancelled`, or `no-run` when nothing was dispatched). + pub status: String, +} + +/// Which output stream an [`ExecNotification`] delta belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ExecStream { + /// Standard output. + Stdout, + /// Standard error. + Stderr, +} + +/// A streaming notification an environment plugin emits mid-exec during a +/// [`METHOD_ENVIRONMENT_EXEC_STREAM`] call. +/// +/// The runtime wraps these into [`NOTIFICATION_ENVIRONMENT_OUTPUT`] JSON-RPC +/// notifications and forwards them to the host on the same channel as the +/// eventual [`ExecResponse`] reply. This mirrors +/// `animus-provider-protocol`'s `AgentNotification` server-streaming surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ExecNotification { + /// Incremental stdout/stderr the command has produced. Maps to + /// [`NOTIFICATION_ENVIRONMENT_OUTPUT`]. + Output { + /// Handle id of the environment this exec runs in. + handle_id: String, + /// Which stream this delta belongs to. + stream: ExecStream, + /// The output delta (UTF-8). + text: String, + }, + /// One journal event from an in-flight [`METHOD_ENVIRONMENT_EXEC_SESSION`], + /// forwarded verbatim from the node's own workflow journal. Maps to + /// [`NOTIFICATION_ENVIRONMENT_JOURNAL`]. + Journal { + /// Handle id of the environment this session runs in. + handle_id: String, + /// The node-local run id this event belongs to. + #[serde(default, skip_serializing_if = "Option::is_none")] + workflow_id: Option, + /// The journal event kind (e.g. `phase_started`, `output_chunk`, + /// `tool_call`, `run_failed`). + event_kind: String, + /// Phase this event belongs to, when phase-scoped. + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_id: Option, + /// Event status discriminator, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, + /// Event timestamp (RFC 3339). + ts: String, + /// The event's full payload, forwarded verbatim. + payload: Value, + /// True on the final event of the session (the run reached a terminal + /// status), signalling the host to settle. + #[serde(default, skip_serializing_if = "is_false")] + terminal: bool, + }, +} + +impl ExecNotification { + /// Wire-method constant for the JSON-RPC notification this variant maps to. + pub fn method(&self) -> &'static str { + match self { + ExecNotification::Output { .. } => NOTIFICATION_ENVIRONMENT_OUTPUT, + ExecNotification::Journal { .. } => NOTIFICATION_ENVIRONMENT_JOURNAL, + } + } + + /// The wire payload for the notification (i.e. its `params`). + pub fn payload(&self) -> Value { + match self { + ExecNotification::Output { + handle_id, + stream, + text, + } => serde_json::json!({ + "handle_id": handle_id, + "stream": stream, + "text": text, + }), + ExecNotification::Journal { + handle_id, + workflow_id, + event_kind, + phase_id, + status, + ts, + payload, + terminal, + } => serde_json::json!({ + "handle_id": handle_id, + "workflow_id": workflow_id, + "event_kind": event_kind, + "phase_id": phase_id, + "status": status, + "ts": ts, + "payload": payload, + "terminal": terminal, + }), + } + } +} + +// ===================================================================== +// Teardown +// ===================================================================== + +/// Request payload for [`METHOD_ENVIRONMENT_TEARDOWN`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TeardownRequest { + /// The prepared context to dispose of. + pub handle: EnvironmentHandle, +} + +/// Response payload for [`METHOD_ENVIRONMENT_TEARDOWN`]. Empty on success; the +/// wire-level error shape is `animus_plugin_protocol::RpcError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +pub struct TeardownResponse {} + +// ===================================================================== +// Node management (list / get / teardown_node / reap) +// ===================================================================== + +/// A managed environment instance (a "node") as reported by the node-management +/// surface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentNode { + /// Substrate-native id (railway service id, container id, ...). + pub id: String, + /// Human-facing name (e.g. `animus-run-`). + pub name: String, + /// Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`, + /// `CRASHED`, `unknown`, ...). + pub state: String, + /// The animus run id this node serves, when recoverable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + /// Image / impl ref backing the node, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + /// Creation timestamp (ISO 8601) when the substrate exposes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// True when the node has no live owning run (a reap candidate). + pub orphan: bool, +} + +/// Request payload for [`METHOD_ENVIRONMENT_LIST`] (no parameters). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ListNodesRequest {} + +/// Response payload for [`METHOD_ENVIRONMENT_LIST`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ListNodesResponse { + /// Every known environment node. + #[serde(default)] + pub nodes: Vec, +} + +/// Request payload for [`METHOD_ENVIRONMENT_GET`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct GetNodeRequest { + /// Substrate id or name of the node to describe. + pub id: String, +} + +/// Response payload for [`METHOD_ENVIRONMENT_GET`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct GetNodeResponse { + /// The node, or null when no node matched. + #[serde(default)] + pub node: Option, +} + +/// Request payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TeardownNodeRequest { + /// Substrate id or name of the node to destroy. + pub id: String, +} + +/// Response payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`]. Idempotent — an +/// already-gone node yields an empty `deleted`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TeardownNodeResponse { + /// Substrate ids actually deleted. + #[serde(default)] + pub deleted: Vec, +} + +/// Request payload for [`METHOD_ENVIRONMENT_REAP`]. With no fields set, reap +/// deletes only dead nodes (always safe — a live node is never dead). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ReapRequest { + /// Also reap non-dead nodes that have no live owning run (needs `force`). + #[serde(default, skip_serializing_if = "is_false")] + pub all: bool, + /// Required alongside `all` — guards a fresh (no-liveness) reaper from + /// treating every healthy node as an orphan. + #[serde(default, skip_serializing_if = "is_false")] + pub force: bool, + /// Report what WOULD be reaped without deleting anything. + #[serde(default, skip_serializing_if = "is_false")] + pub dry_run: bool, + /// Only reap nodes at least this many seconds old. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub older_than_secs: Option, +} + +/// Response payload for [`METHOD_ENVIRONMENT_REAP`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ReapResponse { + /// Substrate ids deleted (or that WOULD be deleted, when `dry_run`). + #[serde(default)] + pub deleted: Vec, + /// Nodes spared. + #[serde(default)] + pub kept: Vec, + /// Echoes the request's `dry_run`. + #[serde(default)] + pub dry_run: bool, +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn method_constants_match_wire_strings() { + assert_eq!(METHOD_ENVIRONMENT_PREPARE, "environment/prepare"); + assert_eq!(METHOD_ENVIRONMENT_EXEC, "environment/exec"); + assert_eq!(METHOD_ENVIRONMENT_EXEC_STREAM, "environment/exec_stream"); + assert_eq!(METHOD_ENVIRONMENT_TEARDOWN, "environment/teardown"); + assert_eq!(METHOD_ENVIRONMENT_LIST, "environment/list"); + assert_eq!(METHOD_ENVIRONMENT_GET, "environment/get"); + assert_eq!( + METHOD_ENVIRONMENT_TEARDOWN_NODE, + "environment/teardown_node" + ); + assert_eq!(METHOD_ENVIRONMENT_REAP, "environment/reap"); + assert_eq!(NOTIFICATION_ENVIRONMENT_OUTPUT, "environment/output"); + } + + #[test] + fn reap_request_omits_default_flags() { + let value = serde_json::to_value(ReapRequest::default()).expect("serializes"); + assert_eq!(value, serde_json::json!({})); + let full = ReapRequest { + all: true, + force: true, + dry_run: true, + older_than_secs: Some(60), + }; + let decoded: ReapRequest = + serde_json::from_value(serde_json::to_value(&full).expect("ser")).expect("round-trips"); + assert_eq!(decoded, full); + } + + #[test] + fn environment_node_round_trips_and_omits_none() { + let node = EnvironmentNode { + id: "svc-1".to_string(), + name: "animus-run-abc".to_string(), + state: "FAILED".to_string(), + run_id: None, + image: None, + created_at: None, + orphan: true, + }; + let value = serde_json::to_value(&node).expect("serializes"); + assert!(value.get("run_id").is_none()); + assert_eq!(value.get("orphan"), Some(&serde_json::json!(true))); + let decoded: EnvironmentNode = serde_json::from_value(value).expect("round-trips"); + assert_eq!(decoded, node); + } + + #[test] + fn prepare_round_trips_minimum_fields() { + let req = PrepareRequest { + spec: EnvironmentSpec { + kind: "worktree".to_string(), + repos: vec![RepoRef { + url: "https://example.test/org/repo.git".to_string(), + name: None, + git_ref: Some("main".to_string()), + primary: true, + }], + image: None, + resources: None, + env: BTreeMap::new(), + metadata: Value::Null, + }, + }; + let value = serde_json::to_value(&req).expect("serializes"); + // Empty/None fields are omitted for back-compat. + let spec = value.get("spec").and_then(|s| s.as_object()).unwrap(); + assert!(spec.get("image").is_none()); + assert!(spec.get("env").is_none()); + assert!(spec.get("metadata").is_none()); + let decoded: PrepareRequest = serde_json::from_value(value).expect("round-trips"); + assert_eq!(decoded, req); + } + + #[test] + fn exec_response_omits_empty_output() { + let resp = ExecResponse { + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + timed_out: false, + }; + let value = serde_json::to_value(&resp).expect("serializes"); + assert!(value.get("stdout").is_none()); + assert!(value.get("stderr").is_none()); + assert!(value.get("timed_out").is_none()); + assert_eq!(value.get("exit_code"), Some(&serde_json::json!(0))); + } + + #[test] + fn exec_notification_maps_to_wire_method_and_payload() { + let note = ExecNotification::Output { + handle_id: "env-1".to_string(), + stream: ExecStream::Stderr, + text: "boom\n".to_string(), + }; + assert_eq!(note.method(), NOTIFICATION_ENVIRONMENT_OUTPUT); + assert_eq!( + note.payload(), + serde_json::json!({ + "handle_id": "env-1", + "stream": "stderr", + "text": "boom\n", + }) + ); + } + + #[test] + fn teardown_response_is_empty_object() { + let value = serde_json::to_value(TeardownResponse::default()).expect("serializes"); + assert_eq!(value, serde_json::json!({})); + } +} diff --git a/animus-journal-protocol/src/lib.rs b/animus-journal-protocol/src/lib.rs index 60dccf7..3eb9dca 100644 --- a/animus-journal-protocol/src/lib.rs +++ b/animus-journal-protocol/src/lib.rs @@ -281,7 +281,10 @@ mod tests { #[test] fn error_codes_map() { - assert_eq!(JournalError::NotFound("x".into()).code(), error_codes::INVALID_PARAMS); + assert_eq!( + JournalError::NotFound("x".into()).code(), + error_codes::INVALID_PARAMS + ); assert_eq!( JournalError::MethodNotSupported("journal/events".into()).code(), error_codes::METHOD_NOT_SUPPORTED diff --git a/animus-plugin-protocol/Cargo.toml b/animus-plugin-protocol/Cargo.toml index f02e06e..348a369 100644 --- a/animus-plugin-protocol/Cargo.toml +++ b/animus-plugin-protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "animus-plugin-protocol" -version = "0.1.16" +version = "0.1.18" edition = "2021" description = "Wire types for Animus stdio plugin protocol (JSON-RPC 2.0). Foundation crate every Animus plugin depends on." diff --git a/animus-plugin-protocol/src/bin/export_schema.rs b/animus-plugin-protocol/src/bin/export_schema.rs index 690bf9a..c97325d 100644 --- a/animus-plugin-protocol/src/bin/export_schema.rs +++ b/animus-plugin-protocol/src/bin/export_schema.rs @@ -28,7 +28,14 @@ use animus_plugin_protocol::conversation_store::{ ConversationCreateRequest, ConversationCreateResponse, ConversationDeleteRequest, ConversationDeleteResponse, ConversationListRequest, ConversationListResponse, ConversationLoadMessagesRequest, ConversationLoadMessagesResponse, ConversationLoadMetaRequest, - ConversationLoadMetaResponse, ConversationMeta, ConversationSaveMetaRequest, + ConversationLoadMetaResponse, ConversationMeta, ConversationOperationAcceptUserRequest, + ConversationOperationAppendFence, ConversationOperationBeginOutcome, + ConversationOperationBeginRequest, ConversationOperationBeginResponse, + ConversationOperationBindExecutionRequest, ConversationOperationClaim, + ConversationOperationKey, ConversationOperationLoadRequest, ConversationOperationLoadResponse, + ConversationOperationMutationResponse, ConversationOperationReleaseRequest, + ConversationOperationRenewRequest, ConversationOperationStatus, + ConversationOperationTerminalizeRequest, ConversationSaveMetaRequest, ConversationSaveMetaResponse, ConversationScope, ConversationSummary, Visibility, }; use animus_plugin_protocol::{ @@ -150,6 +157,70 @@ pub fn all_schemas() -> Vec<(&'static str, Schema)> { "ConversationDeleteResponse", schema_for!(ConversationDeleteResponse), ), + ( + "ConversationOperationStatus", + schema_for!(ConversationOperationStatus), + ), + ( + "ConversationOperation", + schema_for!(animus_plugin_protocol::conversation_store::ConversationOperation), + ), + ( + "ConversationOperationClaim", + schema_for!(ConversationOperationClaim), + ), + ( + "ConversationOperationBeginOutcome", + schema_for!(ConversationOperationBeginOutcome), + ), + ( + "ConversationOperationBeginRequest", + schema_for!(ConversationOperationBeginRequest), + ), + ( + "ConversationOperationBeginResponse", + schema_for!(ConversationOperationBeginResponse), + ), + ( + "ConversationOperationKey", + schema_for!(ConversationOperationKey), + ), + ( + "ConversationOperationLoadRequest", + schema_for!(ConversationOperationLoadRequest), + ), + ( + "ConversationOperationLoadResponse", + schema_for!(ConversationOperationLoadResponse), + ), + ( + "ConversationOperationRenewRequest", + schema_for!(ConversationOperationRenewRequest), + ), + ( + "ConversationOperationBindExecutionRequest", + schema_for!(ConversationOperationBindExecutionRequest), + ), + ( + "ConversationOperationReleaseRequest", + schema_for!(ConversationOperationReleaseRequest), + ), + ( + "ConversationOperationAcceptUserRequest", + schema_for!(ConversationOperationAcceptUserRequest), + ), + ( + "ConversationOperationTerminalizeRequest", + schema_for!(ConversationOperationTerminalizeRequest), + ), + ( + "ConversationOperationMutationResponse", + schema_for!(ConversationOperationMutationResponse), + ), + ( + "ConversationOperationAppendFence", + schema_for!(ConversationOperationAppendFence), + ), ] } diff --git a/animus-plugin-protocol/src/lib.rs b/animus-plugin-protocol/src/lib.rs index 175f851..e32ac8f 100644 --- a/animus-plugin-protocol/src/lib.rs +++ b/animus-plugin-protocol/src/lib.rs @@ -37,7 +37,7 @@ use serde_json::Value; /// declares its own in [`InitializeParams::protocol_version`]. A plugin and /// host with the same major version are compatible. See `spec.md` for the /// full versioning policy. -pub const PROTOCOL_VERSION: &str = "1.1.0"; +pub const PROTOCOL_VERSION: &str = "1.2.0"; /// Plugin kind for LLM provider plugins (Claude, Codex, Gemini, OpenAI-compat, /// on-prem, ...). @@ -164,6 +164,14 @@ pub const PLUGIN_KIND_DURABLE_STORE: &str = "durable_store"; /// (`memory/put`, `memory/get`, `memory/query`, etc.). pub const PLUGIN_KIND_MEMORY_STORE: &str = "memory_store"; +/// Plugin kind for execution-environment plugins (v0.7). +/// +/// Environment plugins prepare an execution context (git worktree, container, +/// or remote host), run provider harness commands inside it, and tear it down. +/// See `animus-environment-protocol` for the typed RPC surface +/// (`environment/prepare`, `environment/exec`, `environment/teardown`). +pub const PLUGIN_KIND_ENVIRONMENT: &str = "environment"; + /// Plugin kind for the legacy agent-runner sidecar. /// /// The agent-runner sidecar (and its `animus-agent-runner-protocol` crate) @@ -219,6 +227,8 @@ pub enum PluginKind { DurableStore, /// Agent memory store plugin (v0.5). See [`PLUGIN_KIND_MEMORY_STORE`]. MemoryStore, + /// Execution-environment plugin (v0.7). See [`PLUGIN_KIND_ENVIRONMENT`]. + Environment, /// Agent-runner sidecar plugin (v0.5). See [`PLUGIN_KIND_AGENT_RUNNER`]. AgentRunner, /// Any kind not understood by this crate version. Preserves the wire @@ -245,6 +255,7 @@ impl PluginKind { PluginKind::WorkflowJournal => PLUGIN_KIND_WORKFLOW_JOURNAL, PluginKind::DurableStore => PLUGIN_KIND_DURABLE_STORE, PluginKind::MemoryStore => PLUGIN_KIND_MEMORY_STORE, + PluginKind::Environment => PLUGIN_KIND_ENVIRONMENT, PluginKind::AgentRunner => PLUGIN_KIND_AGENT_RUNNER, PluginKind::Other(value) => value.as_str(), } @@ -283,6 +294,7 @@ impl From for PluginKind { PLUGIN_KIND_WORKFLOW_JOURNAL => PluginKind::WorkflowJournal, PLUGIN_KIND_DURABLE_STORE => PluginKind::DurableStore, PLUGIN_KIND_MEMORY_STORE => PluginKind::MemoryStore, + PLUGIN_KIND_ENVIRONMENT => PluginKind::Environment, PLUGIN_KIND_AGENT_RUNNER => PluginKind::AgentRunner, _ => PluginKind::Other(value), } @@ -728,6 +740,26 @@ pub struct PluginManifest { /// up the new hint. #[serde(default, skip_serializing_if = "Option::is_none")] pub notification_buffer_size: Option, + /// Whether this plugin consumes host-injected MCP servers. + /// + /// First-class, plugin-DECLARED capability the kernel reads instead of + /// hardcoding per-tool behavior in name tables (REQUIREMENT-039). A + /// provider plugin sets this to advertise whether it accepts the + /// host-supplied MCP server set (profile/skill MCP endpoints injected via + /// the run request); the kernel gates MCP injection on the declared value + /// rather than a built-in allow-list keyed on the tool name. + /// + /// This is the proof-of-pattern field for a growing family of declared + /// kernel-behavior capabilities (launch template, permission-mode flag, + /// reasoning-effort, default model, ...) that will migrate off the kernel's + /// hardcoded name tables onto the manifest over subsequent slices. + /// + /// Back-compat: `None` means "undeclared" — plugins built against earlier + /// protocol versions omit the field entirely, and the kernel applies its + /// historical default (MCP-capable for provider plugins). Only an explicit + /// `Some(false)` opts a provider out of MCP injection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supports_mcp: Option, } impl PluginManifest { @@ -1053,6 +1085,13 @@ impl From for String { /// | [`METHOD_CONVERSATION_LOAD_MESSAGES`] | [`ConversationLoadMessagesRequest`] | [`ConversationLoadMessagesResponse`] | /// | [`METHOD_CONVERSATION_LIST`] | [`ConversationListRequest`] | [`ConversationListResponse`] | /// | [`METHOD_CONVERSATION_DELETE`] | [`ConversationDeleteRequest`] | [`ConversationDeleteResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_BEGIN`]| [`ConversationOperationBeginRequest`]| [`ConversationOperationBeginResponse`]| +/// | [`METHOD_CONVERSATION_OPERATION_LOAD`]| [`ConversationOperationLoadRequest`] | [`ConversationOperationLoadResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_RENEW`]| [`ConversationOperationRenewRequest`]| [`ConversationOperationMutationResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_BIND_EXECUTION`]| [`ConversationOperationBindExecutionRequest`]| [`ConversationOperationMutationResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_RELEASE`]| [`ConversationOperationReleaseRequest`]| [`ConversationOperationMutationResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_ACCEPT_USER`]| [`ConversationOperationAcceptUserRequest`]| [`ConversationOperationMutationResponse`] | +/// | [`METHOD_CONVERSATION_OPERATION_TERMINALIZE`]| [`ConversationOperationTerminalizeRequest`]| [`ConversationOperationMutationResponse`] | /// /// Cross-process serialization of concurrent turns (the in-tree store's /// `try_lock_conversation`) is intentionally **NOT** on the wire — it is a @@ -1092,19 +1131,38 @@ impl From for String { /// /// # Scope identity /// -/// Every request carries `project_root` and `repo_scope` (the repository -/// scope id, as `config_source` does) so a multi-tenant backend can isolate -/// conversations per scope. +/// Every request carries `tenant_id`, `project_root`, and `repo_scope` (the +/// repository scope id, as `config_source` does) so a shared backend can +/// isolate conversations even when two tenants mount the same repository. +/// `tenant_id` is an opaque, transport-selected workspace/tenant key. User +/// input MUST NOT select it. Authenticated backends MUST compare it with the +/// transport-asserted actor tenant and fail closed on absence or mismatch. /// /// # Ownership and visibility /// /// [`ConversationMeta`] carries `owner` (the portal's authenticated user id; -/// `None` = unowned/legacy) and `visibility` ([`Visibility`], default +/// `None` = explicitly configured legacy data) and `visibility` ([`Visibility`], default /// [`Visibility::Private`]). These are the foundation for per-user history /// with sharing. The query-layer filtering — "X's own conversations PLUS any -/// `Shared` ones" — is requested via [`ConversationListRequest::as_user`]; -/// a backend that ignores it simply returns everything (the in-tree store's -/// behavior, which has no auth context). +/// `Shared` ones" — is represented by [`ConversationListRequest::as_user`]. +/// Authenticated stores treat it as a consistency assertion against their call +/// context, never as authority. The in-tree store may use it directly because +/// it has no authenticated transport context. +/// +/// # Canonical agent identity and revisions +/// +/// `agent_id` is a durable canonical profile binding, not caller-projected +/// decoration. Once non-null, a backend MUST reject attempts to replace or +/// clear it. Creation may stamp it atomically; the first bound send may set it +/// from null. Every accepted meta mutation advances `revision`, and +/// `expected_revision` on save MUST be enforced atomically with the write. +/// +/// A keyed turn may reserve that revision before its user message is appended. +/// In that case `active_operation_id` records which durable operation owns the +/// reservation so the same operation can recover after a crash. Backends MUST +/// persist and compare that field as part of the same `save_meta` CAS. It is +/// internal concurrency state: create requests cannot set it, and list +/// summaries intentionally omit it. pub mod conversation_store { use super::*; @@ -1122,6 +1180,260 @@ pub mod conversation_store { pub const METHOD_CONVERSATION_LIST: &str = "conversation/list"; /// `conversation/delete` — permanently remove a conversation (idempotent). pub const METHOD_CONVERSATION_DELETE: &str = "conversation/delete"; + /// `conversation/operation_begin` — atomically admit, replay, or reject a keyed turn. + pub const METHOD_CONVERSATION_OPERATION_BEGIN: &str = "conversation/operation_begin"; + /// `conversation/operation_load` — load the durable operation receipt. + pub const METHOD_CONVERSATION_OPERATION_LOAD: &str = "conversation/operation_load"; + /// `conversation/operation_renew` — renew lease ownership. + pub const METHOD_CONVERSATION_OPERATION_RENEW: &str = "conversation/operation_renew"; + /// `conversation/operation_bind_execution` — bind the resolved execution snapshot. + pub const METHOD_CONVERSATION_OPERATION_BIND_EXECUTION: &str = + "conversation/operation_bind_execution"; + /// `conversation/operation_release` — release an unaccepted pending admission. + pub const METHOD_CONVERSATION_OPERATION_RELEASE: &str = "conversation/operation_release"; + /// `conversation/operation_accept_user` — record canonical user-message acceptance. + pub const METHOD_CONVERSATION_OPERATION_ACCEPT_USER: &str = + "conversation/operation_accept_user"; + /// `conversation/operation_terminalize` — record one immutable terminal outcome. + pub const METHOD_CONVERSATION_OPERATION_TERMINALIZE: &str = + "conversation/operation_terminalize"; + /// Capability proving that operation receipts and leases are shared by all + /// hosts using this conversation backend. + pub const CAPABILITY_CONVERSATION_OPERATIONS_SHARED_V1: &str = + "conversation_operations_shared_v1"; + /// Capability proving that an assistant message carrying + /// [`ConversationOperationAppendFence`] is appended only while that exact + /// shared operation lease is active. Backends MUST enforce the predicate + /// atomically with the message insert; a check followed by a separate + /// insert does not satisfy this capability. + pub const CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1: &str = + "conversation_operation_fenced_append_v1"; + + /// Durable lifecycle state for an idempotent application chat send. + #[allow(missing_docs)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + #[serde(rename_all = "snake_case")] + pub enum ConversationOperationStatus { + Pending, + UserAccepted, + Completed, + AssistantFailed, + AssistantInterrupted, + } + + impl ConversationOperationStatus { + /// Whether no provider execution may ever be admitted for this operation again. + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Completed | Self::AssistantFailed | Self::AssistantInterrupted + ) + } + } + + /// Canonical durable operation state. Lease credentials are returned only + /// on an acquired begin and are not part of replay/load receipts. + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperation { + pub operation_id: String, + pub conversation_id: String, + pub caller_key: String, + pub user_message_id: String, + pub assistant_message_id: String, + pub status: ConversationOperationStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_message: Option, + } + + /// Lease-bearing claim returned only to the host that owns shared authority. + #[allow(missing_docs)] + #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationClaim { + #[serde(flatten)] + pub operation: ConversationOperation, + pub lease_token: String, + pub lease_expires_at: i64, + #[serde(default)] + pub recovered: bool, + } + + impl std::fmt::Debug for ConversationOperationClaim { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConversationOperationClaim") + .field("operation", &self.operation) + .field("lease_token", &"[REDACTED]") + .field("lease_expires_at", &self.lease_expires_at) + .field("recovered", &self.recovered) + .finish() + } + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + #[serde(rename_all = "snake_case")] + pub enum ConversationOperationBeginOutcome { + Acquired, + Replay, + InProgress, + Conflict, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationBeginRequest { + #[serde(flatten)] + pub scope: ConversationScope, + pub conversation_id: String, + #[schemars(length(min = 1, max = 128), regex(pattern = r"^[A-Za-z0-9._:-]+$"))] + pub caller_key: String, + pub request_hash: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_user: Option, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationBeginResponse { + pub outcome: ConversationOperationBeginOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claim: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + } + + /// Shared key used by load and all lease-owned mutations. The backend + /// still derives actor and tenant authority from the authenticated context. + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationKey { + #[serde(flatten)] + pub scope: ConversationScope, + pub conversation_id: String, + #[schemars(length(min = 1, max = 128), regex(pattern = r"^[A-Za-z0-9._:-]+$"))] + pub caller_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_user: Option, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationLoadRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationLoadResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationRenewRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + pub operation_id: String, + pub lease_token: String, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationBindExecutionRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + pub operation_id: String, + pub lease_token: String, + pub execution_hash: String, + /// Rebinding is permitted only for a reclaimed pending lease before + /// canonical user acceptance; ordinary claims may only bind once. + #[serde(default)] + pub allow_rebind: bool, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationReleaseRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + pub operation_id: String, + pub lease_token: String, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationAcceptUserRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + pub operation_id: String, + pub lease_token: String, + pub user_seq: u64, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationTerminalizeRequest { + #[serde(flatten)] + pub key: ConversationOperationKey, + pub operation_id: String, + pub lease_token: String, + pub status: ConversationOperationStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assistant_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_message: Option, + } + + #[allow(missing_docs)] + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationMutationResponse { + pub changed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + } + + /// Lease identity required when a shared-authority runtime persists the + /// assistant message produced by a keyed operation. + /// + /// A backend advertising + /// [`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`] MUST append the + /// message in one transaction whose predicate matches the authenticated + /// tenant and actor, repository and conversation, caller key, operation + /// id, lease token, unexpired backend-clock lease, `user_accepted` state, + /// and the conversation's active operation reservation. A stale fence is + /// rejected without inserting a message or changing conversation meta. + #[allow(missing_docs)] + #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct ConversationOperationAppendFence { + #[schemars(length(min = 1, max = 128), regex(pattern = r"^[A-Za-z0-9._:-]+$"))] + pub caller_key: String, + pub operation_id: String, + pub lease_token: String, + } + + impl std::fmt::Debug for ConversationOperationAppendFence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConversationOperationAppendFence") + .field("caller_key", &self.caller_key) + .field("operation_id", &self.operation_id) + .field("lease_token", &"[REDACTED]") + .finish() + } + } /// Visibility of a conversation. Controls whether [`ConversationListRequest::as_user`] /// filtering surfaces it to users other than its `owner`. @@ -1137,16 +1449,32 @@ pub mod conversation_store { } /// Conversation metadata — the continuity pointer, identity, and the - /// ownership/visibility fields that power per-user history. + /// immutable ownership/visibility fields that power per-user history. /// /// The shape matches the kernel's on-disk `meta.json` exactly so existing /// filesystem conversations and plugin-backed ones are interchangeable. - /// `owner` and `visibility` use serde defaults so legacy `meta.json` - /// files (which lack both) still deserialize as unowned + private. + /// Identity/concurrency fields use serde defaults so legacy `meta.json` + /// files still deserialize as unbound, revision zero, unowned + private. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ConversationMeta { /// Stable conversation id. pub id: String, + /// Canonical configured agent profile bound to this conversation. + /// `None` keeps legacy and intentionally unbound conversations neutral. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Monotonic optimistic-concurrency token. Legacy metas start at zero. + #[serde(default)] + pub revision: u64, + /// Durable operation that owns the current revision reservation. + /// + /// `None` means no keyed operation owns a reservation. A present id + /// MUST contain 1..=128 ASCII alphanumeric or `._:-` characters. + /// Backends expose it through load-meta and accept it through + /// save-meta so the owning operation can recover after a crash. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128), regex(pattern = r"^[A-Za-z0-9._:-]+$"))] + pub active_operation_id: Option, /// Wrapped tool that currently owns the native session. `None` until /// the first turn completes. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1167,8 +1495,10 @@ pub mod conversation_store { /// Count of persisted turns (user + assistant). #[serde(default)] pub message_count: u64, - /// Authenticated user id that owns this conversation. `None` = unowned - /// (legacy on-disk conversations, or ones created without `--as-user`). + /// Authenticated user id that owns this conversation. Authenticated + /// stores stamp this from their call context and MUST NOT accept an + /// ordinary metadata update that changes or clears it. `None` is + /// reserved for explicitly configured legacy data. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, /// Visibility. Defaults to [`Visibility::Private`] for legacy metas. @@ -1213,6 +1543,12 @@ pub mod conversation_store { pub struct ConversationSummary { /// Conversation id. pub id: String, + /// Canonical configured agent profile bound to this conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Current optimistic-concurrency token. + #[serde(default)] + pub revision: u64, /// Title, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub title: Option, @@ -1234,11 +1570,22 @@ pub mod conversation_store { pub visibility: Visibility, } - /// Fields common to every conversation-store request: the project + scope - /// identity a multi-tenant backend partitions on. Flattened into each + /// Fields common to every conversation-store request: tenant, project, and + /// repository identity a shared backend partitions on. Flattened into each /// request so the wire payload stays flat. - #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct ConversationScope { + /// Opaque server-selected tenant/workspace partition key. + /// + /// The authenticated application layer derives this value from its + /// session and forwards the same value in the transport actor. A user + /// supplied workspace id is not authoritative. Authenticated stores + /// MUST fail closed if this field is absent or disagrees with the + /// transport-asserted actor tenant. Omission remains wire-compatible + /// only for explicitly configured single-tenant legacy stores. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128))] + pub tenant_id: Option, /// Absolute project root path of the calling CLI/daemon. #[serde(default, skip_serializing_if = "Option::is_none")] pub project_root: Option, @@ -1256,7 +1603,14 @@ pub mod conversation_store { /// Explicit conversation id; the backend assigns one when `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, - /// Owner to stamp onto the new conversation's meta. + /// Canonical agent binding to stamp atomically at creation time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + /// Legacy owner compatibility assertion. + /// + /// Authenticated stores MUST stamp the owner from their call context; + /// when this field is present they may require it to match, but MUST + /// never treat it as caller authority. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, /// Initial visibility for the new conversation. @@ -1301,8 +1655,13 @@ pub mod conversation_store { /// Scope identity. #[serde(flatten)] pub scope: ConversationScope, - /// The meta to persist. + /// The meta to persist. `meta.owner` is an immutable assertion: an + /// ordinary save MUST NOT change or clear the stored owner. pub meta: ConversationMeta, + /// Apply only when the stored meta still has this revision. Backends + /// must enforce this atomically with the write (compare-and-swap). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_revision: Option, /// Acting user id, when known. A backend MAY use it to authorize the /// mutation. `None` for unscoped/admin access. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1323,6 +1682,12 @@ pub mod conversation_store { pub id: String, /// The turn to append. pub message: ChatMessage, + /// Required for assistant messages produced by a keyed operation on a + /// shared backend. Omitted for ordinary messages and legacy/local + /// stores. Supporting backends advertise + /// [`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation_fence: Option, /// Acting user id, when known. A backend MAY use it to authorize the /// mutation. `None` for unscoped/admin access. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1487,11 +1852,13 @@ mod tests { name: "x".to_string(), version: "0.1.0".to_string(), plugin_kind: PluginKind::Custom.to_string(), + plugin_kinds: Vec::new(), description: "x".to_string(), protocol_version: "1.0.0".to_string(), capabilities: vec![], env_required: vec![], notification_buffer_size: None, + supports_mcp: None, }; let value = serde_json::to_value(&manifest).unwrap(); assert!( @@ -1502,6 +1869,10 @@ mod tests { value.get("notification_buffer_size").is_none(), "unset notification_buffer_size must not be serialized for back-compat" ); + assert!( + value.get("supports_mcp").is_none(), + "unset supports_mcp must not be serialized for back-compat" + ); assert_eq!(value.get("plugin_kind"), Some(&serde_json::json!("custom"))); assert_eq!(manifest.kind(), PluginKind::Custom); } @@ -1522,6 +1893,44 @@ mod tests { assert_eq!(manifest.notification_buffer_size, Some(1024)); } + #[test] + fn manifest_supports_mcp_round_trips() { + // Explicit opt-out parses to Some(false). + let value = serde_json::json!({ + "name": "animus-provider-portal", + "version": "0.1.0", + "plugin_kind": "provider", + "description": "Out-of-tree provider", + "protocol_version": "1.2.0", + "capabilities": ["agent/run"], + "supports_mcp": false + }); + let manifest: PluginManifest = + serde_json::from_value(value).expect("manifest should parse"); + assert_eq!(manifest.supports_mcp, Some(false)); + + // A manifest that sets it serializes the field back out. + let encoded = serde_json::to_value(&manifest).unwrap(); + assert_eq!(encoded.get("supports_mcp"), Some(&serde_json::json!(false))); + } + + #[test] + fn manifest_absent_supports_mcp_is_none() { + // Older plugins omit the field entirely -> undeclared (None), so the + // kernel keeps its historical default. + let value = serde_json::json!({ + "name": "animus-provider-legacy", + "version": "0.1.0", + "plugin_kind": "provider", + "description": "Legacy provider", + "protocol_version": "1.0.0", + "capabilities": ["agent/run"] + }); + let manifest: PluginManifest = + serde_json::from_value(value).expect("manifest should parse"); + assert_eq!(manifest.supports_mcp, None); + } + #[test] fn health_status_serializes_snake_case() { let v = serde_json::to_value(HealthStatus::Degraded).unwrap(); @@ -1645,7 +2054,29 @@ mod tests { // A pre-existing meta.json lacks `owner` and `visibility` entirely. let legacy = r#"{"id":"conv-x","created_at":"2026-06-08T00:00:00Z","updated_at":"2026-06-08T00:00:00Z"}"#; let meta: ConversationMeta = serde_json::from_str(legacy).expect("legacy meta must parse"); - assert_eq!(meta.owner, None, "missing owner must default to None (unowned)"); + assert_eq!( + meta.agent_id, None, + "legacy conversations must remain unbound" + ); + assert_eq!( + meta.revision, 0, + "legacy conversations start at revision zero" + ); + assert_eq!( + meta.active_operation_id, None, + "legacy conversations have no active operation" + ); + assert!( + serde_json::to_value(&meta) + .unwrap() + .get("active_operation_id") + .is_none(), + "an absent operation id remains absent on the legacy wire shape" + ); + assert_eq!( + meta.owner, None, + "missing owner must default to None (unowned)" + ); assert_eq!( meta.visibility, Visibility::Private, @@ -1653,6 +2084,95 @@ mod tests { ); } + #[test] + fn conversation_agent_binding_round_trips_on_meta_summary_and_create() { + use conversation_store::{ + ConversationCreateRequest, ConversationLoadMetaResponse, ConversationMeta, + ConversationSaveMetaRequest, ConversationScope, ConversationSummary, + }; + + let meta: ConversationMeta = serde_json::from_value(serde_json::json!({ + "id": "conv-agent", + "agent_id": "researcher", + "revision": 4, + "active_operation_id": "chat.op:request-42", + "created_at": "2026-07-25T00:00:00Z", + "updated_at": "2026-07-25T00:00:00Z" + })) + .expect("bound meta must parse"); + assert_eq!(meta.agent_id.as_deref(), Some("researcher")); + assert_eq!(meta.revision, 4); + assert_eq!( + meta.active_operation_id.as_deref(), + Some("chat.op:request-42") + ); + assert_eq!( + serde_json::to_value(&meta).unwrap()["agent_id"], + "researcher" + ); + + let load = ConversationLoadMetaResponse { + meta: Some(meta.clone()), + }; + assert_eq!( + serde_json::to_value(load).unwrap()["meta"]["active_operation_id"], + "chat.op:request-42" + ); + + let summary: ConversationSummary = serde_json::from_value(serde_json::json!({ + "id": "conv-agent", + "agent_id": "researcher", + "revision": 4, + "message_count": 0, + "updated_at": "2026-07-25T00:00:00Z" + })) + .expect("bound summary must parse"); + assert_eq!(summary.agent_id.as_deref(), Some("researcher")); + + let create: ConversationCreateRequest = serde_json::from_value(serde_json::json!({ + "project_root": "/repo", + "agent_id": "researcher" + })) + .expect("bound create request must parse"); + assert_eq!(create.agent_id.as_deref(), Some("researcher")); + + let save = ConversationSaveMetaRequest { + scope: ConversationScope::default(), + meta, + expected_revision: Some(4), + as_user: Some("user-1".to_string()), + }; + let encoded = serde_json::to_value(save).unwrap(); + assert_eq!(encoded["expected_revision"], 4); + assert_eq!(encoded["meta"]["agent_id"], "researcher"); + assert_eq!(encoded["meta"]["active_operation_id"], "chat.op:request-42"); + } + + #[test] + fn conversation_active_operation_schema_has_safe_id_contract() { + use conversation_store::{ + ConversationCreateRequest, ConversationMeta, ConversationSummary, + }; + use schemars::schema_for; + + let schema = serde_json::to_value(schema_for!(ConversationMeta)).unwrap(); + let field = &schema["properties"]["active_operation_id"]; + assert_eq!(field["maxLength"], 128); + assert_eq!(field["minLength"], 1); + assert_eq!(field["pattern"], r"^[A-Za-z0-9._:-]+$"); + assert!( + !schema["required"] + .as_array() + .is_some_and(|required| required.iter().any(|name| name == "active_operation_id")), + "legacy metadata must not require active_operation_id" + ); + + let create = serde_json::to_value(schema_for!(ConversationCreateRequest)).unwrap(); + assert!(create["properties"].get("active_operation_id").is_none()); + let summary = serde_json::to_value(schema_for!(ConversationSummary)).unwrap(); + assert!(summary["properties"].get("active_operation_id").is_none()); + } + #[test] fn conversation_visibility_serializes_snake_case() { use conversation_store::Visibility; @@ -1666,11 +2186,101 @@ mod tests { ); } + #[test] + fn conversation_append_operation_fence_is_additive_and_exact() { + use conversation_store::{ + ChatMessage, ConversationAppendMessageRequest, ConversationOperationAppendFence, + ConversationScope, + }; + + let legacy: ConversationAppendMessageRequest = serde_json::from_value(serde_json::json!({ + "tenant_id": "tenant-1", + "repo_scope": "scope-1", + "id": "conv-1", + "message": { + "seq": 1, + "role": "assistant", + "content": "answer", + "recorded_at": "2026-07-26T00:00:00Z" + } + })) + .expect("legacy append remains parseable"); + assert_eq!(legacy.operation_fence, None); + + let fenced = ConversationAppendMessageRequest { + scope: ConversationScope { + tenant_id: Some("tenant-1".to_string()), + project_root: None, + repo_scope: Some("scope-1".to_string()), + }, + id: "conv-1".to_string(), + message: ChatMessage { + seq: 1, + role: "assistant".to_string(), + content: "answer".to_string(), + recorded_at: "2026-07-26T00:00:00Z".to_string(), + tool: None, + model: None, + usage: None, + cost_usd: None, + blocks: Vec::new(), + }, + operation_fence: Some(ConversationOperationAppendFence { + caller_key: "request-42".to_string(), + operation_id: "operation-7".to_string(), + lease_token: "secret-token".to_string(), + }), + as_user: Some("user-1".to_string()), + }; + let encoded = serde_json::to_value(fenced).unwrap(); + assert_eq!(encoded["operation_fence"]["caller_key"], "request-42"); + assert_eq!(encoded["operation_fence"]["operation_id"], "operation-7"); + assert_eq!(encoded["operation_fence"]["lease_token"], "secret-token"); + + let fence = ConversationOperationAppendFence { + caller_key: "request-42".to_string(), + operation_id: "operation-7".to_string(), + lease_token: "never-debug-this-fence".to_string(), + }; + let debug = format!("{fence:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("never-debug-this-fence")); + } + + #[test] + fn conversation_operation_claim_debug_redacts_lease_token() { + use conversation_store::{ + ConversationOperation, ConversationOperationClaim, ConversationOperationStatus, + }; + let claim = ConversationOperationClaim { + operation: ConversationOperation { + operation_id: "operation-7".to_string(), + conversation_id: "conv-1".to_string(), + caller_key: "request-42".to_string(), + user_message_id: "user-message-1".to_string(), + assistant_message_id: "assistant-message-1".to_string(), + status: ConversationOperationStatus::UserAccepted, + execution_hash: None, + user_seq: Some(0), + assistant_seq: None, + error_code: None, + error_message: None, + }, + lease_token: "never-print-this-token".to_string(), + lease_expires_at: 1_800_000_000, + recovered: false, + }; + let debug = format!("{claim:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("never-print-this-token")); + } + #[test] fn conversation_list_request_flattens_scope() { use conversation_store::{ConversationListRequest, ConversationScope}; let req = ConversationListRequest { scope: ConversationScope { + tenant_id: Some("workspace-7".to_string()), project_root: Some("/repo".to_string()), repo_scope: Some("scope-1".to_string()), }, @@ -1678,12 +2288,64 @@ mod tests { }; let encoded = serde_json::to_value(&req).unwrap(); // Scope fields are flattened to the top level, not nested under "scope". - assert_eq!(encoded.get("project_root"), Some(&serde_json::json!("/repo"))); - assert_eq!(encoded.get("repo_scope"), Some(&serde_json::json!("scope-1"))); + assert_eq!( + encoded.get("tenant_id"), + Some(&serde_json::json!("workspace-7")) + ); + assert_eq!( + encoded.get("project_root"), + Some(&serde_json::json!("/repo")) + ); + assert_eq!( + encoded.get("repo_scope"), + Some(&serde_json::json!("scope-1")) + ); assert_eq!(encoded.get("as_user"), Some(&serde_json::json!("user-7"))); assert!( encoded.get("scope").is_none(), "scope must be flattened, not nested" ); } + + #[test] + fn conversation_tenant_scope_is_optional_bounded_and_opaque() { + use conversation_store::{ + ConversationAppendMessageRequest, ConversationCreateRequest, ConversationDeleteRequest, + ConversationListRequest, ConversationLoadMessagesRequest, ConversationLoadMetaRequest, + ConversationSaveMetaRequest, ConversationScope, + }; + use schemars::schema_for; + + let schema = serde_json::to_value(schema_for!(ConversationScope)).unwrap(); + let field = &schema["properties"]["tenant_id"]; + assert_eq!(field["minLength"], 1); + assert_eq!(field["maxLength"], 128); + assert!(field.get("pattern").is_none(), "tenant ids are opaque"); + assert!( + !schema["required"] + .as_array() + .is_some_and(|required| required.iter().any(|name| name == "tenant_id")), + "legacy wire payloads remain parseable; stores decide whether legacy mode is configured" + ); + + let scope: ConversationScope = serde_json::from_value(serde_json::json!({ + "tenant_id": "workspace / opaque:alpha" + })) + .expect("opaque tenant ids must round trip"); + assert_eq!(scope.tenant_id.as_deref(), Some("workspace / opaque:alpha")); + + let request_schemas = [ + serde_json::to_value(schema_for!(ConversationCreateRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationLoadMetaRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationSaveMetaRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationAppendMessageRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationLoadMessagesRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationListRequest)).unwrap(), + serde_json::to_value(schema_for!(ConversationDeleteRequest)).unwrap(), + ]; + assert!(request_schemas.iter().all(|request| { + request["properties"]["tenant_id"]["maxLength"] == 128 + && request["properties"]["tenant_id"]["minLength"] == 1 + })); + } } diff --git a/animus-plugin-runtime/Cargo.toml b/animus-plugin-runtime/Cargo.toml index af76117..7a119f4 100644 --- a/animus-plugin-runtime/Cargo.toml +++ b/animus-plugin-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "animus-plugin-runtime" -version = "0.2.1" +version = "0.2.2" edition = "2021" [lib] diff --git a/animus-plugin-runtime/src/lib.rs b/animus-plugin-runtime/src/lib.rs index d6684ce..a10e9aa 100644 --- a/animus-plugin-runtime/src/lib.rs +++ b/animus-plugin-runtime/src/lib.rs @@ -87,6 +87,7 @@ impl ProviderInfo { name: self.plugin_name.to_string(), version: self.plugin_version.to_string(), plugin_kind: animus_plugin_protocol::PLUGIN_KIND_PROVIDER.to_string(), + plugin_kinds: Vec::new(), description: self.description.to_string(), protocol_version: PROTOCOL_VERSION.to_string(), capabilities: vec![ @@ -97,6 +98,7 @@ impl ProviderInfo { ], env_required: Vec::new(), notification_buffer_size: None, + supports_mcp: None, } } @@ -107,6 +109,7 @@ impl ProviderInfo { name: self.plugin_name.to_string(), version: self.plugin_version.to_string(), plugin_kind: animus_plugin_protocol::PLUGIN_KIND_PROVIDER.to_string(), + plugin_kinds: Vec::new(), description: Some(self.description.to_string()), }, capabilities: PluginCapabilities { diff --git a/animus-plugin-runtime/src/plugin.rs b/animus-plugin-runtime/src/plugin.rs index 2a9ff59..b6e7722 100644 --- a/animus-plugin-runtime/src/plugin.rs +++ b/animus-plugin-runtime/src/plugin.rs @@ -204,6 +204,7 @@ pub struct Plugin { mcp_tools: Vec, env_required: Vec, notification_buffer_size: Option, + supports_mcp: Option, kind_capabilities: HashMap, method_handlers: HashMap, @@ -239,6 +240,7 @@ impl Plugin { mcp_tools: Vec::new(), env_required: Vec::new(), notification_buffer_size: None, + supports_mcp: None, kind_capabilities: HashMap::new(), method_handlers: HashMap::new(), notification_handlers: HashMap::new(), @@ -338,6 +340,16 @@ impl Plugin { self } + /// Declare whether this plugin consumes host-injected MCP servers. + /// + /// Sets [`PluginManifest::supports_mcp`]. Leaving it unset (the default) + /// omits the field so the kernel applies its historical default; call this + /// to explicitly opt in or out (REQUIREMENT-039). + pub fn supports_mcp(mut self, supports_mcp: bool) -> Self { + self.supports_mcp = Some(supports_mcp); + self + } + /// Add a typed per-kind capability blob, surfaced in /// `InitializeResult.kind_capabilities`. pub fn kind_capability(mut self, kind: impl Into, capability: KindCapability) -> Self { @@ -487,11 +499,13 @@ impl Plugin { name: self.name.clone(), version: self.version.clone(), plugin_kind: self.plugin_kind.clone(), + plugin_kinds: Vec::new(), description: self.description.clone(), protocol_version: self.protocol_version.clone(), capabilities, env_required: self.env_required.clone(), notification_buffer_size: self.notification_buffer_size, + supports_mcp: self.supports_mcp, } } @@ -506,6 +520,7 @@ impl Plugin { name: self.name.clone(), version: self.version.clone(), plugin_kind: self.plugin_kind.clone(), + plugin_kinds: Vec::new(), description: if self.description.is_empty() { None } else { diff --git a/animus-plugin-runtime/src/provider_main.rs b/animus-plugin-runtime/src/provider_main.rs index 3b3a8ca..7d3f1f3 100644 --- a/animus-plugin-runtime/src/provider_main.rs +++ b/animus-plugin-runtime/src/provider_main.rs @@ -410,11 +410,17 @@ fn print_manifest_and_exit(info: &PluginInfo, capabilities: &PluginCapabilities) name: info.name.clone(), version: info.version.clone(), plugin_kind: info.plugin_kind.clone(), + plugin_kinds: info.plugin_kinds.clone(), description: info.description.clone().unwrap_or_default(), protocol_version: PROTOCOL_VERSION.to_string(), capabilities: capabilities.methods.clone(), env_required: Vec::new(), notification_buffer_size: None, + // Undeclared: the kernel applies its historical default (provider + // plugins are MCP-capable). Auto-mapping from `ProviderCapabilities.mcp` + // is deferred because that flag defaults `false` and would regress + // providers relying on the implicit default (REQUIREMENT-039). + supports_mcp: None, }; let mut stdout = io::stdout().lock(); let _ = writeln!( @@ -1100,6 +1106,7 @@ mod tests { name: "stub".into(), version: "0".into(), plugin_kind: "provider".into(), + plugin_kinds: Vec::new(), description: None, }; let extras = vec!["$harness/oai-style".to_string()]; diff --git a/animus-plugin-runtime/src/subject.rs b/animus-plugin-runtime/src/subject.rs index 4469ba0..da962ef 100644 --- a/animus-plugin-runtime/src/subject.rs +++ b/animus-plugin-runtime/src/subject.rs @@ -425,6 +425,7 @@ mod tests { name: "stub".into(), version: "0.0.0".into(), plugin_kind: PLUGIN_KIND_SUBJECT_BACKEND.into(), + plugin_kinds: Vec::new(), description: Some("stub".into()), }; let plugin = subject_plugin(info, StubBackend); @@ -453,6 +454,7 @@ mod tests { name: "stub".into(), version: "0.0.0".into(), plugin_kind: PLUGIN_KIND_SUBJECT_BACKEND.into(), + plugin_kinds: Vec::new(), description: Some("stub".into()), }; let plugin = subject_plugin_with_kind_aliases(info, StubBackend, ["task"]); @@ -479,6 +481,7 @@ mod tests { name: "stub".into(), version: "0.0.0".into(), plugin_kind: PLUGIN_KIND_SUBJECT_BACKEND.into(), + plugin_kinds: Vec::new(), description: None, }; let plugin = subject_plugin(info, StubBackend); diff --git a/animus-plugin-runtime/src/transport.rs b/animus-plugin-runtime/src/transport.rs index 33a4cde..43c9f7f 100644 --- a/animus-plugin-runtime/src/transport.rs +++ b/animus-plugin-runtime/src/transport.rs @@ -277,11 +277,13 @@ fn print_manifest_and_exit(info: &PluginInfo, capabilities: &PluginCapabilities) name: info.name.clone(), version: info.version.clone(), plugin_kind: info.plugin_kind.clone(), + plugin_kinds: info.plugin_kinds.clone(), description: info.description.clone().unwrap_or_default(), protocol_version: PROTOCOL_VERSION.to_string(), capabilities: capabilities.methods.clone(), env_required: Vec::new(), notification_buffer_size: None, + supports_mcp: None, }; let mut stdout = io::stdout().lock(); let _ = writeln!( diff --git a/animus-subject-protocol/Cargo.toml b/animus-subject-protocol/Cargo.toml index 2a88d21..d253ff6 100644 --- a/animus-subject-protocol/Cargo.toml +++ b/animus-subject-protocol/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "animus-subject-protocol" -version = "0.1.16" +version = "0.2.0" edition = "2021" description = "SubjectBackend trait + normalized Subject schema for Animus subject backend plugins (Linear, Jira, GitHub Issues, Notion, native task store, etc.)." [dependencies] +animus-actor = { path = "../animus-actor" } animus-plugin-protocol = { path = "../animus-plugin-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/animus-subject-protocol/src/bin/export_schema.rs b/animus-subject-protocol/src/bin/export_schema.rs index 81cf989..2e1c463 100644 --- a/animus-subject-protocol/src/bin/export_schema.rs +++ b/animus-subject-protocol/src/bin/export_schema.rs @@ -26,8 +26,10 @@ use std::process::ExitCode; use animus_subject_protocol::{ ChangeKind, CustomFieldKind, CustomFieldSpec, DeleteSubjectRequest, DeleteSubjectResponse, - StatusDispatchHint, Subject, SubjectAttachment, SubjectChangedEvent, SubjectFilter, SubjectId, - SubjectList, SubjectPatch, SubjectSchema, SubjectStatus, SubjectUnwatchRequest, + StatusDispatchHint, Subject, SubjectAttachment, SubjectChangedEvent, SubjectCreateRequestV2, + SubjectDeleteRequestV2, SubjectFilter, SubjectGetRequestV2, SubjectId, SubjectList, + SubjectListRequestV2, SubjectPatch, SubjectRequestContext, SubjectSchema, SubjectStatus, + SubjectStatusRequestV2, SubjectUnwatchRequest, SubjectUpdateRequestV2, }; use schemars::{schema_for, Schema}; @@ -67,6 +69,25 @@ pub fn all_schemas() -> Vec<(&'static str, Schema)> { ("SubjectAttachment", schema_for!(SubjectAttachment)), ("SubjectFilter", schema_for!(SubjectFilter)), ("SubjectList", schema_for!(SubjectList)), + ("SubjectRequestContext", schema_for!(SubjectRequestContext)), + ("SubjectListRequestV2", schema_for!(SubjectListRequestV2)), + ("SubjectGetRequestV2", schema_for!(SubjectGetRequestV2)), + ( + "SubjectCreateRequestV2", + schema_for!(SubjectCreateRequestV2), + ), + ( + "SubjectUpdateRequestV2", + schema_for!(SubjectUpdateRequestV2), + ), + ( + "SubjectStatusRequestV2", + schema_for!(SubjectStatusRequestV2), + ), + ( + "SubjectDeleteRequestV2", + schema_for!(SubjectDeleteRequestV2), + ), ("SubjectPatch", schema_for!(SubjectPatch)), ("DeleteSubjectRequest", schema_for!(DeleteSubjectRequest)), ("DeleteSubjectResponse", schema_for!(DeleteSubjectResponse)), diff --git a/animus-subject-protocol/src/lib.rs b/animus-subject-protocol/src/lib.rs index 7d27f7a..21d3a3a 100644 --- a/animus-subject-protocol/src/lib.rs +++ b/animus-subject-protocol/src/lib.rs @@ -38,6 +38,7 @@ use std::collections::BTreeMap; use std::pin::Pin; +use animus_actor::Actor; use animus_plugin_protocol::{error_codes, HealthCheckResult, RpcError}; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -82,6 +83,38 @@ pub const METHOD_SUBJECT_UNWATCH: &str = "subject/unwatch"; /// `subject/schema` — capability declaration; returns [`SubjectSchema`]. pub const METHOD_SUBJECT_SCHEMA: &str = "subject/schema"; +/// Actor-scoped v2 `subject/list`. +/// +/// V2 has a distinct method family so an authenticated caller can never +/// downgrade into a v1 backend that ignores unknown actor fields. +pub const METHOD_SUBJECT_LIST_V2: &str = "subject/v2/list"; + +/// Actor-scoped v2 `subject/get`. +pub const METHOD_SUBJECT_GET_V2: &str = "subject/v2/get"; + +/// Actor-scoped v2 `subject/create`. +pub const METHOD_SUBJECT_CREATE_V2: &str = "subject/v2/create"; + +/// Actor-scoped v2 `subject/update`. +pub const METHOD_SUBJECT_UPDATE_V2: &str = "subject/v2/update"; + +/// Actor-scoped v2 `subject/status`. +pub const METHOD_SUBJECT_STATUS_V2: &str = "subject/v2/status"; + +/// Actor-scoped v2 `subject/delete`. +pub const METHOD_SUBJECT_DELETE_V2: &str = "subject/v2/delete"; + +/// Actor-scoped subject wire version. +pub const SUBJECT_ACTOR_PROTOCOL_VERSION: u32 = 2; + +/// Build an actor-scoped v2 method for a concrete subject kind. +/// +/// For example, `subject_v2_kind_method("task", "list")` returns +/// `"task/v2/list"`. +pub fn subject_v2_kind_method(kind: &str, verb: &str) -> String { + format!("{kind}/v2/{verb}") +} + /// `subject/changed` — notification method emitted by `subject/watch` /// streams. pub const NOTIFICATION_SUBJECT_CHANGED: &str = "subject/changed"; @@ -368,6 +401,115 @@ pub struct SubjectList { pub fetched_at: DateTime, } +// ===================================================================== +// Actor-scoped v2 requests +// ===================================================================== + +/// Authenticated request context asserted by the application transport. +/// +/// The actor is required on every v2 subject call. Correlation and +/// idempotency values are opaque transport identifiers: the kernel relays +/// them and the owning backend decides how to record or enforce them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectRequestContext { + /// Authenticated caller. Claims remain advisory; ownership is the stable + /// `(user_id, tenant_id)` pair. + pub actor: Actor, + + /// Optional request identifier for tracing one transport request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + + /// Optional correlation identifier spanning related requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + + /// Optional idempotency key for a mutation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +impl SubjectRequestContext { + /// Construct a context for an authenticated actor. + pub fn for_actor(actor: Actor) -> Self { + Self { + actor, + request_id: None, + correlation_id: None, + idempotency_key: None, + } + } +} + +/// Actor-scoped v2 list request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectListRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Subject filters. Ownership is always an additional backend-enforced + /// predicate and cannot be overridden by this filter. + #[serde(default)] + pub filter: SubjectFilter, +} + +/// Actor-scoped v2 get request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectGetRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Subject identifier. + pub id: SubjectId, +} + +/// Actor-scoped v2 create request. +/// +/// `payload` is backend-defined so dynamic subject kinds retain their +/// schema-driven fields while the authentication envelope stays typed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectCreateRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Kind for the canonical `subject/v2/create` form. A kind-prefixed + /// method may omit this and derive it from the method. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Backend-defined create fields. + #[serde(default)] + pub payload: Value, +} + +/// Actor-scoped v2 update request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectUpdateRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Subject identifier. + pub id: SubjectId, + /// Backend-defined update patch. + #[serde(default)] + pub patch: Value, +} + +/// Actor-scoped v2 status mutation request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectStatusRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Subject identifier. + pub id: SubjectId, + /// Backend-native or normalized status token. + pub status: String, +} + +/// Actor-scoped v2 delete request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SubjectDeleteRequestV2 { + /// Authenticated request context. + pub context: SubjectRequestContext, + /// Subject identifier. + pub id: SubjectId, +} + // ===================================================================== // Patches // ===================================================================== @@ -800,6 +942,36 @@ pub trait SubjectBackend: Send + Sync + 'static { async fn health(&self) -> Result; } +/// Actor-scoped v2 subject backend contract. +/// +/// This trait is intentionally separate from [`SubjectBackend`]. The legacy +/// trait remains the v1 compatibility edge; authenticated application calls +/// use the distinct v2 method family and this trait, so a backend cannot +/// silently discard identity by implementing only v1. +#[async_trait] +pub trait ActorScopedSubjectBackend: Send + Sync + 'static { + /// Return only subjects owned by the request actor. + async fn list_v2(&self, request: SubjectListRequestV2) -> Result; + + /// Fetch a subject owned by the request actor. + async fn get_v2(&self, request: SubjectGetRequestV2) -> Result; + + /// Create a subject owned by the request actor. + async fn create_v2(&self, request: SubjectCreateRequestV2) -> Result; + + /// Update a subject owned by the request actor. + async fn update_v2(&self, request: SubjectUpdateRequestV2) -> Result; + + /// Change status for a subject owned by the request actor. + async fn status_v2(&self, request: SubjectStatusRequestV2) -> Result; + + /// Delete a subject owned by the request actor. + async fn delete_v2( + &self, + request: SubjectDeleteRequestV2, + ) -> Result; +} + #[cfg(test)] mod tests { use super::*; @@ -1063,6 +1235,51 @@ mod tests { assert_eq!(back, req); } + #[test] + fn actor_scoped_v2_request_requires_typed_context() { + let actor = Actor { + user_id: "alice".into(), + tenant_id: Some("tenant-a".into()), + claims: vec!["advisory".into()], + }; + let request = SubjectGetRequestV2 { + context: SubjectRequestContext { + actor: actor.clone(), + request_id: Some("request-1".into()), + correlation_id: Some("workflow-7".into()), + idempotency_key: None, + }, + id: SubjectId::new("task:TASK-1"), + }; + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value["context"]["actor"]["user_id"], "alice"); + assert_eq!(value["context"]["actor"]["tenant_id"], "tenant-a"); + assert_eq!(value["context"]["correlation_id"], "workflow-7"); + assert!( + serde_json::from_value::(serde_json::json!({ + "id": "task:TASK-1" + })) + .is_err() + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + request + ); + } + + #[test] + fn actor_scoped_methods_are_distinct_from_legacy_v1() { + assert_eq!(SUBJECT_ACTOR_PROTOCOL_VERSION, 2); + assert_eq!(METHOD_SUBJECT_LIST_V2, "subject/v2/list"); + assert_eq!(METHOD_SUBJECT_GET_V2, "subject/v2/get"); + assert_eq!(METHOD_SUBJECT_CREATE_V2, "subject/v2/create"); + assert_eq!(METHOD_SUBJECT_UPDATE_V2, "subject/v2/update"); + assert_eq!(METHOD_SUBJECT_STATUS_V2, "subject/v2/status"); + assert_eq!(METHOD_SUBJECT_DELETE_V2, "subject/v2/delete"); + assert_eq!(subject_v2_kind_method("task", "list"), "task/v2/list"); + assert_ne!(METHOD_SUBJECT_LIST_V2, METHOD_SUBJECT_LIST); + } + #[test] fn delete_subject_response_round_trips_json() { let resp = DeleteSubjectResponse { ok: true }; @@ -1480,8 +1697,13 @@ fn subject_metadata_is_empty(value: &Value) -> bool { /// ao-cli's `protocol` crate. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SubjectDispatch { - /// The subject this dispatch targets. - pub subject: SubjectRef, + /// The subject this dispatch targets, or `None` for a genuinely + /// subjectless run — a workflow dispatched with NO bound subject (subject + /// template vars are simply absent downstream). Omitted from the wire when + /// absent; older payloads always carry a subject, so this stays + /// backward-tolerant on deserialize. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, /// Workflow YAML ref to execute (e.g., `"standard"`, `"quick-fix"`). pub workflow_ref: String, /// Optional initial input JSON for workflow variables. @@ -1498,6 +1720,13 @@ pub struct SubjectDispatch { pub trigger_source: String, /// When the dispatch was created. pub requested_at: DateTime, + /// Caller identity this dispatch runs as. `None` is a system/global run + /// (the legacy default). Owner-scoped schedules attach a kernel-minted + /// [`Actor`] here (see the schedule's `owner_id`); the daemon relays it to + /// the workflow runner so downstream provider + plugin channels scope to + /// the owner. Additive and back-compat: omitted from the wire when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor: Option, } impl SubjectDispatch { @@ -1510,45 +1739,72 @@ impl SubjectDispatch { requested_at: DateTime, ) -> Self { Self { - subject, + subject: Some(subject), workflow_ref: workflow_ref.into(), input: None, vars: std::collections::HashMap::new(), priority: None, trigger_source: trigger_source.into(), requested_at, + actor: None, } } - /// Borrow the subject id. - pub fn subject_id(&self) -> &str { - self.subject.id() + /// Construct a genuinely subjectless dispatch (no bound subject). The run + /// carries NO subject identity; downstream subject template vars are simply + /// absent. + pub fn subjectless( + workflow_ref: impl Into, + trigger_source: impl Into, + requested_at: DateTime, + ) -> Self { + Self { + subject: None, + workflow_ref: workflow_ref.into(), + input: None, + vars: std::collections::HashMap::new(), + priority: None, + trigger_source: trigger_source.into(), + requested_at, + actor: None, + } } - /// Borrow the subject kind. - pub fn subject_kind(&self) -> &str { - self.subject.kind() + /// Borrow the subject, or `None` for a subjectless dispatch. + pub fn subject(&self) -> Option<&SubjectRef> { + self.subject.as_ref() } - /// Return the stable queue-key form for this dispatch. - pub fn subject_key(&self) -> String { - self.subject.subject_key() + /// Borrow the subject id, or `None` for a subjectless dispatch. + pub fn subject_id(&self) -> Option<&str> { + self.subject.as_ref().map(SubjectRef::id) + } + + /// Borrow the subject kind, or `None` for a subjectless dispatch. + pub fn subject_kind(&self) -> Option<&str> { + self.subject.as_ref().map(SubjectRef::kind) + } + + /// Return the stable queue-key form for this dispatch, or `None` for a + /// subjectless dispatch (nothing to key/dedup on). + pub fn subject_key(&self) -> Option { + self.subject.as_ref().map(SubjectRef::subject_key) } /// Return the task id if this dispatch's subject is a built-in task. pub fn task_id(&self) -> Option<&str> { - self.subject.task_id() + self.subject.as_ref().and_then(SubjectRef::task_id) } /// Return the requirement id if this dispatch's subject is a built-in /// requirement. pub fn requirement_id(&self) -> Option<&str> { - self.subject.requirement_id() + self.subject.as_ref().and_then(SubjectRef::requirement_id) } /// Return the schedule id if this dispatch's subject is schedule-driven. pub fn schedule_id(&self) -> Option<&str> { - self.subject.schedule_id() + self.subject.as_ref().and_then(SubjectRef::schedule_id) } /// Attach input JSON (fluent builder). @@ -1562,6 +1818,13 @@ impl SubjectDispatch { self.vars = vars; self } + + /// Attach the caller identity this dispatch runs as (fluent builder). + /// `None` leaves the dispatch as a system/global run. + pub fn with_actor(mut self, actor: Option) -> Self { + self.actor = actor; + self + } } #[cfg(test)] @@ -1598,12 +1861,30 @@ mod subject_ref_dispatch_tests { "ready-queue", Utc::now(), ); - assert_eq!(d.subject_id(), "TASK-1"); - assert_eq!(d.subject_kind(), SUBJECT_KIND_TASK); + assert_eq!(d.subject_id(), Some("TASK-1")); + assert_eq!(d.subject_kind(), Some(SUBJECT_KIND_TASK)); assert_eq!(d.task_id(), Some("TASK-1")); assert_eq!(d.requirement_id(), None); } + #[test] + fn subjectless_dispatch_has_absent_subject() { + let d = SubjectDispatch::subjectless("relate", "manual", Utc::now()); + assert!(d.subject().is_none()); + assert_eq!(d.subject_id(), None); + assert_eq!(d.subject_kind(), None); + assert_eq!(d.subject_key(), None); + assert_eq!(d.task_id(), None); + + // Subjectless dispatch omits `subject` from the wire entirely. + let v = serde_json::to_value(&d).unwrap(); + assert!(v.get("subject").is_none(), "subject must be absent: {v}"); + + // ... and round-trips back to an absent subject. + let back: SubjectDispatch = serde_json::from_value(v).unwrap(); + assert!(back.subject().is_none()); + } + #[test] fn subject_key_is_id_for_builtins_and_qualified_for_generic() { assert_eq!(SubjectRef::task("TASK-1").subject_key(), "TASK-1"); diff --git a/protocol/src/agent_runner.rs b/protocol/src/agent_runner.rs index 013c982..e66a97b 100644 --- a/protocol/src/agent_runner.rs +++ b/protocol/src/agent_runner.rs @@ -16,15 +16,45 @@ pub struct AgentRunRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AgentRunEvent { - Started { run_id: RunId, timestamp: Timestamp }, - OutputChunk { run_id: RunId, stream_type: OutputStreamType, text: String }, - Metadata { run_id: RunId, cost: Option, tokens: Option }, - Error { run_id: RunId, error: String }, - Finished { run_id: RunId, exit_code: Option, duration_ms: u64 }, - ToolCall { run_id: RunId, tool_info: ToolCallInfo }, - ToolResult { run_id: RunId, result_info: ToolResultInfo }, - Artifact { run_id: RunId, artifact_info: ArtifactInfo }, - Thinking { run_id: RunId, content: String }, + Started { + run_id: RunId, + timestamp: Timestamp, + }, + OutputChunk { + run_id: RunId, + stream_type: OutputStreamType, + text: String, + }, + Metadata { + run_id: RunId, + cost: Option, + tokens: Option, + }, + Error { + run_id: RunId, + error: String, + }, + Finished { + run_id: RunId, + exit_code: Option, + duration_ms: u64, + }, + ToolCall { + run_id: RunId, + tool_info: ToolCallInfo, + }, + ToolResult { + run_id: RunId, + result_info: ToolResultInfo, + }, + Artifact { + run_id: RunId, + artifact_info: ArtifactInfo, + }, + Thinking { + run_id: RunId, + content: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -121,7 +151,10 @@ pub struct IpcAuthRequest { impl IpcAuthRequest { pub fn new(token: impl Into) -> Self { - Self { kind: IpcAuthRequestKind::IpcAuth, token: token.into() } + Self { + kind: IpcAuthRequestKind::IpcAuth, + token: token.into(), + } } } @@ -162,11 +195,21 @@ pub struct IpcAuthResult { impl IpcAuthResult { pub fn ok() -> Self { - Self { kind: IpcAuthResultKind::IpcAuthResult, ok: true, code: None, message: None } + Self { + kind: IpcAuthResultKind::IpcAuthResult, + ok: true, + code: None, + message: None, + } } pub fn rejected(code: IpcAuthFailureCode, message: impl Into) -> Self { - Self { kind: IpcAuthResultKind::IpcAuthResult, ok: false, code: Some(code), message: Some(message.into()) } + Self { + kind: IpcAuthResultKind::IpcAuthResult, + ok: false, + code: Some(code), + message: Some(message.into()), + } } } diff --git a/protocol/src/config.rs b/protocol/src/config.rs index ccd478c..3bb6de7 100644 --- a/protocol/src/config.rs +++ b/protocol/src/config.rs @@ -177,7 +177,9 @@ impl Config { return override_path; } - dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".animus") + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".animus") } pub fn load_global() -> Result { @@ -191,8 +193,9 @@ impl Config { /// consent). pub fn save_global(&self) -> Result<()> { let dir = Self::global_config_dir(); - fs::create_dir_all(&dir) - .with_context(|| format!("Failed to create global config directory {}", dir.display()))?; + fs::create_dir_all(&dir).with_context(|| { + format!("Failed to create global config directory {}", dir.display()) + })?; let path = dir.join("config.json"); let json = serde_json::to_string_pretty(self)?; fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; @@ -212,8 +215,9 @@ impl Config { } pub fn load_from_dir(config_dir: &Path) -> Result { - fs::create_dir_all(config_dir) - .with_context(|| format!("Failed to create config directory {}", config_dir.display()))?; + fs::create_dir_all(config_dir).with_context(|| { + format!("Failed to create config directory {}", config_dir.display()) + })?; Self::load_or_initialize(&config_dir.join("config.json")) } @@ -235,7 +239,9 @@ impl Config { } fn config_path(project_root: &str) -> Result { - let project_path = PathBuf::from(project_root).canonicalize().context("Invalid project root")?; + let project_path = PathBuf::from(project_root) + .canonicalize() + .context("Invalid project root")?; Ok(project_path.join(".animus").join("config.json")) } @@ -266,7 +272,11 @@ impl Config { pub fn ensure_token_exists(config_dir: &Path) -> Result<()> { let config_path = config_dir.join("config.json"); let mut config = Self::load_from_dir(config_dir)?; - if config.agent_runner_token.as_deref().is_none_or(|t| t.trim().is_empty()) { + if config + .agent_runner_token + .as_deref() + .is_none_or(|t| t.trim().is_empty()) + { config.agent_runner_token = Some(Uuid::new_v4().to_string()); let json = serde_json::to_string_pretty(&config)?; fs::write(&config_path, json) @@ -276,7 +286,10 @@ impl Config { } pub fn get_token(&self) -> Result { - normalize_token("agent_runner_token", self.agent_runner_token.clone().unwrap_or_default()) + normalize_token( + "agent_runner_token", + self.agent_runner_token.clone().unwrap_or_default(), + ) } pub fn claude_profile(&self, name: &str) -> Option<&ClaudeProfileEntry> { @@ -322,7 +335,11 @@ pub fn daemon_events_log_path() -> PathBuf { /// and MCP-prefixed variants. pub fn default_allowed_tool_prefixes(agent_id: &str) -> Vec { let normalized = agent_id.trim().to_ascii_lowercase(); - let mut prefixes = vec!["animus.".to_string(), "mcp__animus__".to_string(), "mcp.animus.".to_string()]; + let mut prefixes = vec![ + "animus.".to_string(), + "mcp__animus__".to_string(), + "mcp.animus.".to_string(), + ]; if !normalized.is_empty() { prefixes.push(format!("{normalized}.")); @@ -396,10 +413,16 @@ mod tests { let entry = &config.mcp_servers["my-db"]; assert_eq!(entry.command, "/usr/local/bin/db-mcp"); assert_eq!(entry.args, vec!["--port", "5432"]); - assert_eq!(entry.env.get("DB_HOST").map(String::as_str), Some("localhost")); + assert_eq!( + entry.env.get("DB_HOST").map(String::as_str), + Some("localhost") + ); assert_eq!(entry.assign_to, vec!["swe"]); assert_eq!( - config.claude_profiles["work"].env.get("CLAUDE_CONFIG_DIR").map(String::as_str), + config.claude_profiles["work"] + .env + .get("CLAUDE_CONFIG_DIR") + .map(String::as_str), Some("/Users/test/.claude-work") ); @@ -446,14 +469,19 @@ mod tests { } }"#; let config: Config = serde_json::from_str(json).unwrap(); - let auto = config.auto_update.as_ref().expect("auto_update block should deserialize"); + let auto = config + .auto_update + .as_ref() + .expect("auto_update block should deserialize"); assert_eq!(auto.mode, AutoUpdateMode::Prompt); assert_eq!(auto.channel, AutoUpdateChannel::Prerelease); assert_eq!(auto.check_interval, "PT6H"); let serialized = serde_json::to_string(&config).unwrap(); let round: Config = serde_json::from_str(&serialized).unwrap(); - let round_auto = round.auto_update.expect("auto_update should survive round-trip"); + let round_auto = round + .auto_update + .expect("auto_update should survive round-trip"); assert_eq!(round_auto.mode, AutoUpdateMode::Prompt); assert_eq!(round_auto.channel, AutoUpdateChannel::Prerelease); } @@ -471,7 +499,10 @@ mod tests { fn config_without_metrics_block_deserializes_as_none() { let json = r#"{"agent_runner_token": null}"#; let config: Config = serde_json::from_str(json).unwrap(); - assert!(config.metrics.is_none(), "absent metrics block must remain None"); + assert!( + config.metrics.is_none(), + "absent metrics block must remain None" + ); } #[test] @@ -490,7 +521,10 @@ mod tests { assert_eq!(metrics.enabled, Some(true)); assert_eq!(metrics.endpoint, "https://metrics.example.test/v1/events"); assert_eq!(metrics.batch_interval, "P1D"); - assert_eq!(metrics.install_id.as_deref(), Some("550e8400-e29b-41d4-a716-446655440000")); + assert_eq!( + metrics.install_id.as_deref(), + Some("550e8400-e29b-41d4-a716-446655440000") + ); let serialized = serde_json::to_string(&config).unwrap(); let round: Config = serde_json::from_str(&serialized).unwrap(); @@ -511,7 +545,10 @@ mod tests { #[test] fn metrics_is_enabled_respects_env_kill_switch() { use crate::test_utils::EnvVarGuard; - let metrics = MetricsConfig { enabled: Some(true), ..MetricsConfig::default() }; + let metrics = MetricsConfig { + enabled: Some(true), + ..MetricsConfig::default() + }; { let _guard = EnvVarGuard::set("ANIMUS_METRICS_DISABLE", Some("1")); assert!(!metrics.is_enabled()); diff --git a/protocol/src/config_bundle.rs b/protocol/src/config_bundle.rs index 4332edb..66e6dcd 100644 --- a/protocol/src/config_bundle.rs +++ b/protocol/src/config_bundle.rs @@ -21,7 +21,11 @@ pub struct ConfigBundle { impl ConfigBundle { /// Create a new empty config bundle pub fn new() -> Self { - Self { files: BTreeMap::new(), tasks: Vec::new(), requirements: Vec::new() } + Self { + files: BTreeMap::new(), + tasks: Vec::new(), + requirements: Vec::new(), + } } /// Add a file to the bundle diff --git a/protocol/src/credentials.rs b/protocol/src/credentials.rs index 1f96713..910bfec 100644 --- a/protocol/src/credentials.rs +++ b/protocol/src/credentials.rs @@ -23,7 +23,9 @@ impl Credentials { } } if let Ok(home) = std::env::var("HOME") { - if let Some(creds) = Self::try_load_from(&PathBuf::from(home).join(".animus").join("credentials.json")) { + if let Some(creds) = + Self::try_load_from(&PathBuf::from(home).join(".animus").join("credentials.json")) + { return creds; } } @@ -45,7 +47,8 @@ impl Credentials { continue; } let p = provider.to_ascii_lowercase(); - let matches = model_lower.starts_with(&p) || model_lower.contains(&p) || base_lower.contains(&p); + let matches = + model_lower.starts_with(&p) || model_lower.contains(&p) || base_lower.contains(&p); if matches { return Some(cred.api_key.clone()); } @@ -61,49 +64,97 @@ mod tests { #[test] fn resolve_matches_model_prefix() { let creds = Credentials { - providers: HashMap::from([("minimax".to_string(), ProviderCredential { api_key: "sk-test".to_string() })]), + providers: HashMap::from([( + "minimax".to_string(), + ProviderCredential { + api_key: "sk-test".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("minimax/MiniMax-M2.5", "https://api.minimax.io/v1"), Some("sk-test".to_string())); + assert_eq!( + creds.resolve("minimax/MiniMax-M2.5", "https://api.minimax.io/v1"), + Some("sk-test".to_string()) + ); } #[test] fn resolve_matches_model_contains() { let creds = Credentials { - providers: HashMap::from([("deepseek".to_string(), ProviderCredential { api_key: "sk-ds".to_string() })]), + providers: HashMap::from([( + "deepseek".to_string(), + ProviderCredential { + api_key: "sk-ds".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("deepseek/deepseek-chat", "https://api.deepseek.com/v1"), Some("sk-ds".to_string())); + assert_eq!( + creds.resolve("deepseek/deepseek-chat", "https://api.deepseek.com/v1"), + Some("sk-ds".to_string()) + ); } #[test] fn resolve_matches_api_base() { let creds = Credentials { - providers: HashMap::from([("openrouter".to_string(), ProviderCredential { api_key: "sk-or".to_string() })]), + providers: HashMap::from([( + "openrouter".to_string(), + ProviderCredential { + api_key: "sk-or".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("anthropic/claude-3", "https://openrouter.ai/api/v1"), Some("sk-or".to_string())); + assert_eq!( + creds.resolve("anthropic/claude-3", "https://openrouter.ai/api/v1"), + Some("sk-or".to_string()) + ); } #[test] fn resolve_returns_none_when_no_match() { let creds = Credentials { - providers: HashMap::from([("minimax".to_string(), ProviderCredential { api_key: "sk-test".to_string() })]), + providers: HashMap::from([( + "minimax".to_string(), + ProviderCredential { + api_key: "sk-test".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("deepseek/chat", "https://api.deepseek.com"), None); + assert_eq!( + creds.resolve("deepseek/chat", "https://api.deepseek.com"), + None + ); } #[test] fn resolve_skips_empty_keys() { let creds = Credentials { - providers: HashMap::from([("minimax".to_string(), ProviderCredential { api_key: " ".to_string() })]), + providers: HashMap::from([( + "minimax".to_string(), + ProviderCredential { + api_key: " ".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("minimax/M2.5", "https://api.minimax.io"), None); + assert_eq!( + creds.resolve("minimax/M2.5", "https://api.minimax.io"), + None + ); } #[test] fn resolve_is_case_insensitive() { let creds = Credentials { - providers: HashMap::from([("MiniMax".to_string(), ProviderCredential { api_key: "sk-mm".to_string() })]), + providers: HashMap::from([( + "MiniMax".to_string(), + ProviderCredential { + api_key: "sk-mm".to_string(), + }, + )]), }; - assert_eq!(creds.resolve("minimax/MiniMax-M2.5", "https://api.minimax.io/v1"), Some("sk-mm".to_string())); + assert_eq!( + creds.resolve("minimax/MiniMax-M2.5", "https://api.minimax.io/v1"), + Some("sk-mm".to_string()) + ); } #[test] diff --git a/protocol/src/deploy_config.rs b/protocol/src/deploy_config.rs index dfe0d32..cec4cb3 100644 --- a/protocol/src/deploy_config.rs +++ b/protocol/src/deploy_config.rs @@ -27,7 +27,9 @@ impl DeployConfig { } pub fn load_for_project(project_root: &str) -> Self { - let project_path = PathBuf::from(project_root).join(".animus").join("deploy.json"); + let project_path = PathBuf::from(project_root) + .join(".animus") + .join("deploy.json"); if let Some(project_config) = Self::try_load_from(&project_path) { return project_config.merge_with_global(); } @@ -45,7 +47,9 @@ impl DeployConfig { } pub fn save_for_project(&self, project_root: &str) -> anyhow::Result<()> { - let path = PathBuf::from(project_root).join(".animus").join("deploy.json"); + let path = PathBuf::from(project_root) + .join(".animus") + .join("deploy.json"); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -63,7 +67,11 @@ impl DeployConfig { region: self.region.or(global.region), status: self.status.or(global.status), last_deployed_at: self.last_deployed_at, - machine_ids: if self.machine_ids.is_empty() { global.machine_ids } else { self.machine_ids }, + machine_ids: if self.machine_ids.is_empty() { + global.machine_ids + } else { + self.machine_ids + }, } } diff --git a/protocol/src/error_classification.rs b/protocol/src/error_classification.rs index 4c78005..2abb25e 100644 --- a/protocol/src/error_classification.rs +++ b/protocol/src/error_classification.rs @@ -39,7 +39,10 @@ pub struct ClassifiedError { impl ClassifiedError { pub fn new(kind: ErrorKind, message: impl Into) -> Self { - Self { kind, message: message.into() } + Self { + kind, + message: message.into(), + } } pub const fn kind(&self) -> ErrorKind { @@ -98,7 +101,13 @@ const INVALID_INPUT_PATTERNS: &[&str] = &[ ]; const NOT_FOUND_PATTERNS: &[&str] = &["not found", "no such file or directory", "does not exist"]; const CONFLICT_PATTERNS: &[&str] = &["already", "conflict"]; -const UNAVAILABLE_PATTERNS: &[&str] = &["timed out", "timeout", "connection", "unavailable", "failed to connect"]; +const UNAVAILABLE_PATTERNS: &[&str] = &[ + "timed out", + "timeout", + "connection", + "unavailable", + "failed to connect", +]; pub fn classify_error_message(message: &str) -> (&'static str, i32) { let normalized = message.to_ascii_lowercase(); @@ -128,13 +137,22 @@ mod tests { #[test] fn classify_error_message_marks_invalid_inputs() { - assert_eq!(classify_error_message("required arguments were not provided: --id"), ("invalid_input", 2)); + assert_eq!( + classify_error_message("required arguments were not provided: --id"), + ("invalid_input", 2) + ); } #[test] fn classify_error_message_marks_not_found_paths() { - assert_eq!(classify_error_message("No such file or directory (os error 2)"), ("not_found", 3)); - assert_eq!(classify_error_message("task does not exist"), ("not_found", 3)); + assert_eq!( + classify_error_message("No such file or directory (os error 2)"), + ("not_found", 3) + ); + assert_eq!( + classify_error_message("task does not exist"), + ("not_found", 3) + ); } #[test] @@ -142,8 +160,14 @@ mod tests { let cases = [ ("unknown argument '--bogus' found", ("invalid_input", 2)), ("unrecognized option '--bogus'", ("invalid_input", 2)), - ("CONFIRMATION_REQUIRED: rerun command with --confirm TASK-1", ("invalid_input", 2)), - ("priority must be one of critical|high|medium|low", ("invalid_input", 2)), + ( + "CONFIRMATION_REQUIRED: rerun command with --confirm TASK-1", + ("invalid_input", 2), + ), + ( + "priority must be one of critical|high|medium|low", + ("invalid_input", 2), + ), ("resource already exists", ("conflict", 4)), ("failed to connect to daemon", ("unavailable", 5)), ]; @@ -155,22 +179,37 @@ mod tests { #[test] fn classify_error_message_marks_conflicts() { - assert_eq!(classify_error_message("resource already exists"), ("conflict", 4)); + assert_eq!( + classify_error_message("resource already exists"), + ("conflict", 4) + ); } #[test] fn classify_error_message_marks_unavailable_paths() { - assert_eq!(classify_error_message("timeout while waiting for daemon"), ("unavailable", 5)); + assert_eq!( + classify_error_message("timeout while waiting for daemon"), + ("unavailable", 5) + ); } #[test] fn classify_error_message_keeps_precedence_order() { - assert_eq!(classify_error_message("invalid and not found"), ("invalid_input", 2)); - assert_eq!(classify_error_message("task not found in unavailable registry"), ("not_found", 3)); + assert_eq!( + classify_error_message("invalid and not found"), + ("invalid_input", 2) + ); + assert_eq!( + classify_error_message("task not found in unavailable registry"), + ("not_found", 3) + ); } #[test] fn classify_error_message_defaults_to_internal() { - assert_eq!(classify_error_message("unexpected panic in scheduler loop"), ("internal", 1)); + assert_eq!( + classify_error_message("unexpected panic in scheduler loop"), + ("internal", 1) + ); } } diff --git a/protocol/src/hook_policy.rs b/protocol/src/hook_policy.rs index 8126252..27e1692 100644 --- a/protocol/src/hook_policy.rs +++ b/protocol/src/hook_policy.rs @@ -126,10 +126,16 @@ pub enum HookPolicyError { #[source] source: serde_json::Error, }, - #[error("unsupported hook policy version {found} at {path} (supported: {HOOK_POLICY_VERSION})")] + #[error( + "unsupported hook policy version {found} at {path} (supported: {HOOK_POLICY_VERSION})" + )] Version { path: String, found: u32 }, #[error("invalid regex {pattern:?} in hook policy rule {rule}: {message}")] - InvalidRegex { rule: String, pattern: String, message: String }, + InvalidRegex { + rule: String, + pattern: String, + message: String, + }, } impl HookPolicy { @@ -137,10 +143,15 @@ impl HookPolicy { /// matcher regex up front so evaluation cannot fail later. pub fn load(path: &Path) -> Result { let display = path.display().to_string(); - let raw = - std::fs::read_to_string(path).map_err(|source| HookPolicyError::Read { path: display.clone(), source })?; + let raw = std::fs::read_to_string(path).map_err(|source| HookPolicyError::Read { + path: display.clone(), + source, + })?; let policy: HookPolicy = - serde_json::from_str(&raw).map_err(|source| HookPolicyError::Parse { path: display.clone(), source })?; + serde_json::from_str(&raw).map_err(|source| HookPolicyError::Parse { + path: display.clone(), + source, + })?; policy.validate(&display)?; Ok(policy) } @@ -148,7 +159,10 @@ impl HookPolicy { /// Validate the schema version and matcher regexes. pub fn validate(&self, path: &str) -> Result<(), HookPolicyError> { if self.version != HOOK_POLICY_VERSION { - return Err(HookPolicyError::Version { path: path.to_string(), found: self.version }); + return Err(HookPolicyError::Version { + path: path.to_string(), + found: self.version, + }); } for (index, rule) in self.rules.iter().enumerate() { for matcher in &rule.input_matchers { @@ -171,13 +185,21 @@ impl HookPolicy { /// winning restrictiveness supplies the reason. Rules whose decision is /// `defer` never outrank the default. When nothing matches, the policy's /// `default_decision` is returned. - pub fn evaluate(&self, event: &str, tool_name: &str, tool_input: &serde_json::Value) -> PolicyVerdict { + pub fn evaluate( + &self, + event: &str, + tool_name: &str, + tool_input: &serde_json::Value, + ) -> PolicyVerdict { let mut winner: Option<&HookPolicyRule> = None; for rule in &self.rules { if !rule_matches(rule, event, tool_name, tool_input) { continue; } - if winner.map(|current| rule.decision > current.decision).unwrap_or(true) { + if winner + .map(|current| rule.decision > current.decision) + .unwrap_or(true) + { winner = Some(rule); } } @@ -194,22 +216,38 @@ impl HookPolicy { }, _ => PolicyVerdict { decision: self.default_decision, - reason: (self.default_decision > PolicyDecision::Defer) - .then(|| format!("animus hook policy default decision ({})", self.default_decision.as_str())), + reason: (self.default_decision > PolicyDecision::Defer).then(|| { + format!( + "animus hook policy default decision ({})", + self.default_decision.as_str() + ) + }), rule_id: None, }, } } } -fn rule_matches(rule: &HookPolicyRule, event: &str, tool_name: &str, tool_input: &serde_json::Value) -> bool { +fn rule_matches( + rule: &HookPolicyRule, + event: &str, + tool_name: &str, + tool_input: &serde_json::Value, +) -> bool { if !rule.events.is_empty() && !rule.events.iter().any(|e| e == event) { return false; } - if !rule.tools.is_empty() && !rule.tools.iter().any(|pattern| glob_matches(pattern, tool_name)) { + if !rule.tools.is_empty() + && !rule + .tools + .iter() + .any(|pattern| glob_matches(pattern, tool_name)) + { return false; } - rule.input_matchers.iter().all(|matcher| input_matcher_matches(matcher, tool_input)) + rule.input_matchers + .iter() + .all(|matcher| input_matcher_matches(matcher, tool_input)) } fn input_matcher_matches(matcher: &InputMatcher, tool_input: &serde_json::Value) -> bool { @@ -224,7 +262,9 @@ fn input_matcher_matches(matcher: &InputMatcher, tool_input: &serde_json::Value) // mean the file changed between load and evaluate, so fail safe (no match // is the conservative outcome only for allow rules — but load() makes // this unreachable in practice). - regex::Regex::new(&matcher.regex).map(|re| re.is_match(&haystack)).unwrap_or(false) + regex::Regex::new(&matcher.regex) + .map(|re| re.is_match(&haystack)) + .unwrap_or(false) } fn lookup_field<'a>(value: &'a serde_json::Value, dot_path: &str) -> Option<&'a serde_json::Value> { @@ -241,7 +281,10 @@ pub fn glob_matches(pattern: &str, value: &str) -> bool { match pattern.split_first() { None => value.is_empty(), Some((b'*', rest)) => (0..=value.len()).any(|skip| inner(rest, &value[skip..])), - Some((ch, rest)) => value.split_first().map(|(v, vrest)| v == ch && inner(rest, vrest)).unwrap_or(false), + Some((ch, rest)) => value + .split_first() + .map(|(v, vrest)| v == ch && inner(rest, vrest)) + .unwrap_or(false), } } inner(pattern.as_bytes(), value.as_bytes()) @@ -253,11 +296,22 @@ mod tests { use serde_json::json; fn policy(rules: Vec) -> HookPolicy { - HookPolicy { version: HOOK_POLICY_VERSION, default_decision: PolicyDecision::Defer, rules } + HookPolicy { + version: HOOK_POLICY_VERSION, + default_decision: PolicyDecision::Defer, + rules, + } } fn rule(decision: PolicyDecision) -> HookPolicyRule { - HookPolicyRule { id: None, events: vec![], tools: vec![], input_matchers: vec![], decision, reason: None } + HookPolicyRule { + id: None, + events: vec![], + tools: vec![], + input_matchers: vec![], + decision, + reason: None, + } } #[test] @@ -286,17 +340,27 @@ mod tests { let mut r = rule(PolicyDecision::Deny); r.id = Some("no-force-push".to_string()); r.tools = vec!["Bash".to_string()]; - r.input_matchers = - vec![InputMatcher { field: "command".to_string(), regex: r"git\s+push\b.*(--force|-f)\b".to_string() }]; + r.input_matchers = vec![InputMatcher { + field: "command".to_string(), + regex: r"git\s+push\b.*(--force|-f)\b".to_string(), + }]; r.reason = Some("Force pushes are blocked.".to_string()); let p = policy(vec![r]); - let verdict = p.evaluate("PreToolUse", "Bash", &json!({"command": "git push --force origin main"})); + let verdict = p.evaluate( + "PreToolUse", + "Bash", + &json!({"command": "git push --force origin main"}), + ); assert_eq!(verdict.decision, PolicyDecision::Deny); assert_eq!(verdict.reason.as_deref(), Some("Force pushes are blocked.")); assert_eq!(verdict.rule_id.as_deref(), Some("no-force-push")); - let verdict = p.evaluate("PreToolUse", "Bash", &json!({"command": "git push origin main"})); + let verdict = p.evaluate( + "PreToolUse", + "Bash", + &json!({"command": "git push origin main"}), + ); assert_eq!(verdict.decision, PolicyDecision::Defer); } @@ -308,7 +372,10 @@ mod tests { deny.tools = vec!["Bash".to_string()]; deny.id = Some("deny-all-bash".to_string()); - for rules in [vec![allow.clone(), deny.clone()], vec![deny.clone(), allow.clone()]] { + for rules in [ + vec![allow.clone(), deny.clone()], + vec![deny.clone(), allow.clone()], + ] { let verdict = policy(rules).evaluate("PreToolUse", "Bash", &json!({"command": "ls"})); assert_eq!(verdict.decision, PolicyDecision::Deny); assert_eq!(verdict.rule_id.as_deref(), Some("deny-all-bash")); @@ -327,26 +394,47 @@ mod tests { let mut r = rule(PolicyDecision::Deny); r.events = vec!["PermissionRequest".to_string()]; let p = policy(vec![r]); - assert_eq!(p.evaluate("PreToolUse", "Bash", &json!({})).decision, PolicyDecision::Defer); - assert_eq!(p.evaluate("PermissionRequest", "Bash", &json!({})).decision, PolicyDecision::Deny); + assert_eq!( + p.evaluate("PreToolUse", "Bash", &json!({})).decision, + PolicyDecision::Defer + ); + assert_eq!( + p.evaluate("PermissionRequest", "Bash", &json!({})).decision, + PolicyDecision::Deny + ); } #[test] fn missing_field_never_matches() { let mut r = rule(PolicyDecision::Deny); - r.input_matchers = vec![InputMatcher { field: "command".to_string(), regex: ".*".to_string() }]; + r.input_matchers = vec![InputMatcher { + field: "command".to_string(), + regex: ".*".to_string(), + }]; let p = policy(vec![r]); - assert_eq!(p.evaluate("PreToolUse", "Edit", &json!({"file_path": "/x"})).decision, PolicyDecision::Defer); + assert_eq!( + p.evaluate("PreToolUse", "Edit", &json!({"file_path": "/x"})) + .decision, + PolicyDecision::Defer + ); } #[test] fn dot_path_and_non_string_values() { let mut r = rule(PolicyDecision::Deny); - r.input_matchers = vec![InputMatcher { field: "options.force".to_string(), regex: "^true$".to_string() }]; + r.input_matchers = vec![InputMatcher { + field: "options.force".to_string(), + regex: "^true$".to_string(), + }]; let p = policy(vec![r]); - assert_eq!(p.evaluate("PreToolUse", "X", &json!({"options": {"force": true}})).decision, PolicyDecision::Deny); assert_eq!( - p.evaluate("PreToolUse", "X", &json!({"options": {"force": false}})).decision, + p.evaluate("PreToolUse", "X", &json!({"options": {"force": true}})) + .decision, + PolicyDecision::Deny + ); + assert_eq!( + p.evaluate("PreToolUse", "X", &json!({"options": {"force": false}})) + .decision, PolicyDecision::Defer ); } @@ -355,17 +443,35 @@ mod tests { fn all_input_matchers_must_match() { let mut r = rule(PolicyDecision::Deny); r.input_matchers = vec![ - InputMatcher { field: "command".to_string(), regex: "rm".to_string() }, - InputMatcher { field: "command".to_string(), regex: "-rf".to_string() }, + InputMatcher { + field: "command".to_string(), + regex: "rm".to_string(), + }, + InputMatcher { + field: "command".to_string(), + regex: "-rf".to_string(), + }, ]; let p = policy(vec![r]); - assert_eq!(p.evaluate("PreToolUse", "Bash", &json!({"command": "rm -rf /"})).decision, PolicyDecision::Deny); - assert_eq!(p.evaluate("PreToolUse", "Bash", &json!({"command": "rm x"})).decision, PolicyDecision::Defer); + assert_eq!( + p.evaluate("PreToolUse", "Bash", &json!({"command": "rm -rf /"})) + .decision, + PolicyDecision::Deny + ); + assert_eq!( + p.evaluate("PreToolUse", "Bash", &json!({"command": "rm x"})) + .decision, + PolicyDecision::Defer + ); } #[test] fn default_decision_non_defer_carries_reason() { - let p = HookPolicy { version: HOOK_POLICY_VERSION, default_decision: PolicyDecision::Ask, rules: vec![] }; + let p = HookPolicy { + version: HOOK_POLICY_VERSION, + default_decision: PolicyDecision::Ask, + rules: vec![], + }; let verdict = p.evaluate("PreToolUse", "Bash", &json!({})); assert_eq!(verdict.decision, PolicyDecision::Ask); assert!(verdict.reason.is_some()); @@ -387,7 +493,10 @@ mod tests { default_decision: PolicyDecision::Allow, rules: vec![rule(PolicyDecision::Defer)], }; - assert_eq!(p.evaluate("PreToolUse", "Bash", &json!({})).decision, PolicyDecision::Allow); + assert_eq!( + p.evaluate("PreToolUse", "Bash", &json!({})).decision, + PolicyDecision::Allow + ); } #[test] @@ -395,8 +504,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("hook-policy.v1.json"); - std::fs::write(&path, serde_json::to_string(&json!({"version": 2, "rules": []})).unwrap()).unwrap(); - assert!(matches!(HookPolicy::load(&path), Err(HookPolicyError::Version { found: 2, .. }))); + std::fs::write( + &path, + serde_json::to_string(&json!({"version": 2, "rules": []})).unwrap(), + ) + .unwrap(); + assert!(matches!( + HookPolicy::load(&path), + Err(HookPolicyError::Version { found: 2, .. }) + )); std::fs::write( &path, @@ -407,9 +523,15 @@ mod tests { .unwrap(), ) .unwrap(); - assert!(matches!(HookPolicy::load(&path), Err(HookPolicyError::InvalidRegex { .. }))); - - assert!(matches!(HookPolicy::load(&dir.path().join("missing.json")), Err(HookPolicyError::Read { .. }))); + assert!(matches!( + HookPolicy::load(&path), + Err(HookPolicyError::InvalidRegex { .. }) + )); + + assert!(matches!( + HookPolicy::load(&dir.path().join("missing.json")), + Err(HookPolicyError::Read { .. }) + )); } #[test] @@ -420,7 +542,10 @@ mod tests { id: Some("r".to_string()), events: vec!["PreToolUse".to_string()], tools: vec!["Bash".to_string()], - input_matchers: vec![InputMatcher { field: "command".to_string(), regex: "x".to_string() }], + input_matchers: vec![InputMatcher { + field: "command".to_string(), + regex: "x".to_string(), + }], decision: PolicyDecision::Deny, reason: Some("nope".to_string()), }]); diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index d506a7c..e5f4acf 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -29,9 +29,10 @@ pub use agent_runner::*; pub use common::*; // Explicit re-exports for config helpers used across crates pub use config::{ - cli_tracker_path, daemon_events_log_path, default_allowed_tool_prefixes, metrics_env_disabled, parse_env_bool, - parse_env_bool_opt, AutoUpdateChannel, AutoUpdateConfig, AutoUpdateMode, ClaudeProfileEntry, Config, MetricsConfig, - ProjectMcpServerEntry, SecretsConfig, DEFAULT_METRICS_BATCH_INTERVAL, DEFAULT_METRICS_ENDPOINT, + cli_tracker_path, daemon_events_log_path, default_allowed_tool_prefixes, metrics_env_disabled, + parse_env_bool, parse_env_bool_opt, AutoUpdateChannel, AutoUpdateConfig, AutoUpdateMode, + ClaudeProfileEntry, Config, MetricsConfig, ProjectMcpServerEntry, SecretsConfig, + DEFAULT_METRICS_BATCH_INTERVAL, DEFAULT_METRICS_ENDPOINT, }; pub use config_bundle::ConfigBundle; pub use daemon::*; @@ -41,8 +42,8 @@ pub use error_classification::*; pub use errors::*; pub use model_routing::*; pub use orchestrator::{ - RunnerEvent, SubjectDispatch, SubjectDispatchExt, SubjectExecutionFact, SubjectRef, SUBJECT_KIND_CUSTOM, - SUBJECT_KIND_REQUIREMENT, SUBJECT_KIND_TASK, + RunnerEvent, SubjectDispatch, SubjectDispatchExt, SubjectExecutionFact, SubjectRef, + SUBJECT_KIND_CUSTOM, SUBJECT_KIND_REQUIREMENT, SUBJECT_KIND_TASK, }; pub use output::*; pub use process::*; diff --git a/protocol/src/model_routing.rs b/protocol/src/model_routing.rs index eea4766..e3d396b 100644 --- a/protocol/src/model_routing.rs +++ b/protocol/src/model_routing.rs @@ -18,7 +18,10 @@ pub struct McpRuntimeConfig { impl McpRuntimeConfig { pub fn is_http_transport(&self) -> bool { - self.transport.as_deref().map(|v| v.trim().to_ascii_lowercase()) == Some("http".to_string()) + self.transport + .as_deref() + .map(|v| v.trim().to_ascii_lowercase()) + == Some("http".to_string()) } } @@ -95,9 +98,8 @@ pub fn is_known_tool_id(tool_id: &str) -> bool { pub fn normalize_tool_id(tool_id: &str) -> String { match tool_id.trim().to_ascii_lowercase().as_str() { "open-code" => "opencode".to_string(), - "glm" | "minimax" | "oai" | "animus-oai-runner" | "groq" | "together" | "fireworks" | "mistral" => { - "oai-runner".to_string() - } + "glm" | "minimax" | "oai" | "animus-oai-runner" | "groq" | "together" | "fireworks" + | "mistral" => "oai-runner".to_string(), other => other.to_string(), } } @@ -109,20 +111,29 @@ pub fn canonical_model_id(model_id: &str) -> String { } match trimmed.to_ascii_lowercase().as_str() { - "sonnet" | "claude-sonnet" | "claude-sonnet-latest" | "claude-sonnet-4" => "claude-sonnet-4-6".to_string(), + "sonnet" | "claude-sonnet" | "claude-sonnet-latest" | "claude-sonnet-4" => { + "claude-sonnet-4-6".to_string() + } "claude-sonnet-4.5" | "claude-sonnet-4-5" | "claude-4.5-sonnet" | "claude-4-5-sonnet" => { "claude-sonnet-4-5".to_string() } "claude-sonnet-4.6" | "claude-sonnet-4-6" | "claude-4.6-sonnet" | "claude-4-6-sonnet" => { "claude-sonnet-4-6".to_string() } - "opus" | "claude-opus" | "claude-opus-latest" | "claude-opus-4" => "claude-opus-4-6".to_string(), - "claude-opus-4.1" | "claude-opus-4-1" | "claude-4.1-opus" | "claude-4-1-opus" => "claude-opus-4-1".to_string(), - "claude-opus-4.6" | "claude-opus-4-6" | "claude-4.6-opus" | "claude-4-6-opus" => "claude-opus-4-6".to_string(), - "claude-opus-4.5" | "claude-opus-4-5" | "claude-4.5-opus" | "claude-4-5-opus" => "claude-opus-4-5".to_string(), - "gpt-5.3-codex" | "gpt-5-3-codex" | "gpt5.3-codex" | "gpt5-3-codex" | "gpt_5.3_codex" | "gpt_5_3_codex" => { - "gpt-5.3-codex".to_string() + "opus" | "claude-opus" | "claude-opus-latest" | "claude-opus-4" => { + "claude-opus-4-6".to_string() + } + "claude-opus-4.1" | "claude-opus-4-1" | "claude-4.1-opus" | "claude-4-1-opus" => { + "claude-opus-4-1".to_string() } + "claude-opus-4.6" | "claude-opus-4-6" | "claude-4.6-opus" | "claude-4-6-opus" => { + "claude-opus-4-6".to_string() + } + "claude-opus-4.5" | "claude-opus-4-5" | "claude-4.5-opus" | "claude-4-5-opus" => { + "claude-opus-4-5".to_string() + } + "gpt-5.3-codex" | "gpt-5-3-codex" | "gpt5.3-codex" | "gpt5-3-codex" | "gpt_5.3_codex" + | "gpt_5_3_codex" => "gpt-5.3-codex".to_string(), "gpt-5.3-codex-spark" | "gpt-5-3-codex-spark" | "gpt5.3-codex-spark" @@ -137,19 +148,26 @@ pub fn canonical_model_id(model_id: &str) -> String { "gemini-2.5-flash-lite" | "gemini-flash-lite-2.5" | "gemini-flash-2.5-lite" => { "gemini-2.5-flash-lite".to_string() } - "gemini-3" | "gemini-3.0-pro" | "gemini-3-pro-latest" | "gemini-pro-3" => "gemini-3-pro".to_string(), - "glm-5" | "glm5" | "zai/glm-5" | "z-ai/glm-5" | "zai-coding-plan-glm-5" | "zai-coding-plan/glm-5" => { - "zai-coding-plan/glm-5".to_string() + "gemini-3" | "gemini-3.0-pro" | "gemini-3-pro-latest" | "gemini-pro-3" => { + "gemini-3-pro".to_string() } + "glm-5" + | "glm5" + | "zai/glm-5" + | "z-ai/glm-5" + | "zai-coding-plan-glm-5" + | "zai-coding-plan/glm-5" => "zai-coding-plan/glm-5".to_string(), "minimax-m2.5" | "minimax-m2-5" | "minimax/m2.5" | "minimax/m2-5" | "minimax/minimax-m2.5" | "openrouter/minimax/minimax-m2.7" => "openrouter/minimax/minimax-m2.7".to_string(), - "minimax-m2.1" | "minimax-m2-1" | "minimax/m2.1" | "minimax/m2-1" | "minimax/minimax-m2.1" => { - "minimax/MiniMax-M2.1".to_string() - } + "minimax-m2.1" + | "minimax-m2-1" + | "minimax/m2.1" + | "minimax/m2-1" + | "minimax/minimax-m2.1" => "minimax/MiniMax-M2.1".to_string(), _ => trimmed.to_string(), } } @@ -195,7 +213,10 @@ pub fn tool_for_model_id(model_id: &str) -> &'static str { } pub fn tool_supports_repository_writes(tool_id: &str) -> bool { - matches!(normalize_tool_id(tool_id).as_str(), "codex" | "claude" | "gemini" | "opencode" | "oai-runner") + matches!( + normalize_tool_id(tool_id).as_str(), + "codex" | "claude" | "gemini" | "opencode" | "oai-runner" + ) } pub fn required_api_keys_for_tool(_tool_id: &str) -> &'static [&'static str] { @@ -214,8 +235,14 @@ pub fn default_model_specs() -> Vec<(String, String)> { ("gemini-2.5-flash-lite".to_string(), "gemini".to_string()), ("gemini-3-pro".to_string(), "gemini".to_string()), ("gemini-3.1-pro-preview".to_string(), "gemini".to_string()), - ("openrouter/minimax/minimax-m2.7".to_string(), "oai-runner".to_string()), - ("zai-coding-plan/glm-5".to_string(), "oai-runner".to_string()), + ( + "openrouter/minimax/minimax-m2.7".to_string(), + "oai-runner".to_string(), + ), + ( + "zai-coding-plan/glm-5".to_string(), + "oai-runner".to_string(), + ), ] } @@ -257,18 +284,42 @@ pub struct PhaseCapabilities { impl PhaseCapabilities { pub fn defaults_for_phase(phase_id: &str) -> Self { match phase_id { - "implementation" => { - Self { writes_files: true, requires_commit: true, enforce_product_changes: true, ..Default::default() } - } - "wireframe" | "design" => Self { writes_files: true, is_ui_ux: true, ..Default::default() }, - "ux-research" | "mockup-review" | "ui-design" | "ux-design" => { - Self { is_ui_ux: true, ..Default::default() } - } - "design-review" => Self { is_ui_ux: true, is_review: true, ..Default::default() }, - "research" => Self { is_research: true, ..Default::default() }, - "code-review" | "review" | "architecture" => Self { is_review: true, ..Default::default() }, - "requirements" => Self { is_requirements: true, ..Default::default() }, - "testing" | "test" | "qa" => Self { is_testing: true, ..Default::default() }, + "implementation" => Self { + writes_files: true, + requires_commit: true, + enforce_product_changes: true, + ..Default::default() + }, + "wireframe" | "design" => Self { + writes_files: true, + is_ui_ux: true, + ..Default::default() + }, + "ux-research" | "mockup-review" | "ui-design" | "ux-design" => Self { + is_ui_ux: true, + ..Default::default() + }, + "design-review" => Self { + is_ui_ux: true, + is_review: true, + ..Default::default() + }, + "research" => Self { + is_research: true, + ..Default::default() + }, + "code-review" | "review" | "architecture" => Self { + is_review: true, + ..Default::default() + }, + "requirements" => Self { + is_requirements: true, + ..Default::default() + }, + "testing" | "test" | "qa" => Self { + is_testing: true, + ..Default::default() + }, _ => Self::default(), } } @@ -279,7 +330,8 @@ impl PhaseCapabilities { writes_files: self.writes_files || defaults.writes_files, mutates_state: self.mutates_state || defaults.mutates_state, requires_commit: self.requires_commit || defaults.requires_commit, - enforce_product_changes: self.enforce_product_changes || defaults.enforce_product_changes, + enforce_product_changes: self.enforce_product_changes + || defaults.enforce_product_changes, is_research: self.is_research || defaults.is_research, is_ui_ux: self.is_ui_ux || defaults.is_ui_ux, is_review: self.is_review || defaults.is_review, @@ -313,21 +365,39 @@ mod tests { assert_eq!(canonical_model_id("codex-spark"), "gpt-5.3-codex-spark"); assert_eq!(canonical_model_id("gemini-pro"), "gemini-2.5-pro"); assert_eq!(canonical_model_id("gemini-3.0-pro"), "gemini-3-pro"); - assert_eq!(canonical_model_id("gemini-2.5-flash-lite"), "gemini-2.5-flash-lite"); - assert_eq!(canonical_model_id("gemini-flash-lite-2.5"), "gemini-2.5-flash-lite"); - assert_eq!(canonical_model_id("gemini-flash-2.5-lite"), "gemini-2.5-flash-lite"); + assert_eq!( + canonical_model_id("gemini-2.5-flash-lite"), + "gemini-2.5-flash-lite" + ); + assert_eq!( + canonical_model_id("gemini-flash-lite-2.5"), + "gemini-2.5-flash-lite" + ); + assert_eq!( + canonical_model_id("gemini-flash-2.5-lite"), + "gemini-2.5-flash-lite" + ); assert_eq!(canonical_model_id("glm-5"), "zai-coding-plan/glm-5"); assert_eq!(canonical_model_id("minimax-m2.1"), "minimax/MiniMax-M2.1"); - assert_eq!(canonical_model_id("minimax-m2.5"), "openrouter/minimax/minimax-m2.7"); + assert_eq!( + canonical_model_id("minimax-m2.5"), + "openrouter/minimax/minimax-m2.7" + ); } #[test] fn tool_routing_detects_claude_opencode_and_gemini_families() { assert_eq!(tool_for_model_id("claude-sonnet-4-6"), "claude"); assert_eq!(tool_for_model_id("claude-opus-4-6"), "claude"); - assert_eq!(tool_for_model_id("openrouter/anthropic/claude-sonnet"), "claude"); + assert_eq!( + tool_for_model_id("openrouter/anthropic/claude-sonnet"), + "claude" + ); assert_eq!(tool_for_model_id("zai-coding-plan/glm-5"), "oai-runner"); - assert_eq!(tool_for_model_id("openrouter/minimax/minimax-m2.7"), "oai-runner"); + assert_eq!( + tool_for_model_id("openrouter/minimax/minimax-m2.7"), + "oai-runner" + ); assert_eq!(tool_for_model_id("gemini-2.5-pro"), "gemini"); assert_eq!(tool_for_model_id("gemini-2.5-flash-lite"), "gemini"); assert_eq!(tool_for_model_id("gpt-5.3-codex"), "codex"); @@ -362,7 +432,10 @@ mod tests { #[test] fn merge_with_defaults_ors_config_with_phase_defaults() { - let custom = PhaseCapabilities { writes_files: true, ..Default::default() }; + let custom = PhaseCapabilities { + writes_files: true, + ..Default::default() + }; let merged = custom.merge_with_defaults("research"); assert!(merged.writes_files); assert!(merged.is_research); @@ -370,7 +443,10 @@ mod tests { #[test] fn mutating_state_capability_prevents_strict_read_only_mode() { - let caps = PhaseCapabilities { mutates_state: true, ..Default::default() }; + let caps = PhaseCapabilities { + mutates_state: true, + ..Default::default() + }; assert!(!caps.is_strictly_read_only()); } @@ -379,8 +455,14 @@ mod tests { assert_eq!(default_model_for_tool("claude"), Some("claude-sonnet-4-6")); assert_eq!(default_model_for_tool("codex"), Some("gpt-5.4")); assert_eq!(default_model_for_tool("gemini"), Some("gemini-2.5-pro")); - assert_eq!(default_model_for_tool("opencode"), Some("zai-coding-plan/glm-5")); - assert_eq!(default_model_for_tool("oai-runner"), Some("openrouter/minimax/minimax-m2.7")); + assert_eq!( + default_model_for_tool("opencode"), + Some("zai-coding-plan/glm-5") + ); + assert_eq!( + default_model_for_tool("oai-runner"), + Some("openrouter/minimax/minimax-m2.7") + ); assert_eq!(default_model_for_tool("unknown"), None); } diff --git a/protocol/src/orchestrator.rs b/protocol/src/orchestrator.rs index cdd1f43..5524e68 100644 --- a/protocol/src/orchestrator.rs +++ b/protocol/src/orchestrator.rs @@ -359,7 +359,11 @@ pub struct RequirementsDraftInput { impl Default for RequirementsDraftInput { fn default() -> Self { - Self { include_codebase_scan: true, append_only: true, max_requirements: default_requirements_limit() } + Self { + include_codebase_scan: true, + append_only: true, + max_requirements: default_requirements_limit(), + } } } @@ -465,7 +469,11 @@ pub struct ResourceRequirements { impl Default for ResourceRequirements { fn default() -> Self { - Self { max_cpu_percent: None, max_memory_mb: None, requires_network: true } + Self { + max_cpu_percent: None, + max_memory_mb: None, + requires_network: true, + } } } @@ -575,13 +583,38 @@ pub struct OrchestratorTask { pub dispatch_history: Vec, } -const FRONTEND_TAGS: &[&str] = - &["frontend", "ui", "ux", "design", "react", "web", "landing-page", "design-system", "nextjs"]; - -const FRONTEND_TOKENS: &[&str] = - &["frontend", "ui", "ux", "react", "tailwind", "css", "component", "storybook", "wireframe", "mockup", "nextjs"]; - -const FRONTEND_PHRASES: &[&str] = &["user interface", "user experience", "design system", "landing page"]; +const FRONTEND_TAGS: &[&str] = &[ + "frontend", + "ui", + "ux", + "design", + "react", + "web", + "landing-page", + "design-system", + "nextjs", +]; + +const FRONTEND_TOKENS: &[&str] = &[ + "frontend", + "ui", + "ux", + "react", + "tailwind", + "css", + "component", + "storybook", + "wireframe", + "mockup", + "nextjs", +]; + +const FRONTEND_PHRASES: &[&str] = &[ + "user interface", + "user experience", + "design system", + "landing page", +]; pub fn is_frontend_related_content(tags: &[String], text: &str) -> bool { if tags.iter().any(|tag| { @@ -592,15 +625,25 @@ pub fn is_frontend_related_content(tags: &[String], text: &str) -> bool { } let haystack = text.to_ascii_lowercase(); - let tokenized: String = - haystack.chars().map(|character| if character.is_ascii_alphanumeric() { character } else { ' ' }).collect(); + let tokenized: String = haystack + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + ' ' + } + }) + .collect(); let tokens: HashSet<&str> = tokenized.split_whitespace().collect(); if FRONTEND_TOKENS.iter().any(|needle| tokens.contains(needle)) { return true; } - FRONTEND_PHRASES.iter().any(|needle| haystack.contains(needle)) + FRONTEND_PHRASES + .iter() + .any(|needle| haystack.contains(needle)) } impl OrchestratorTask { @@ -609,7 +652,11 @@ impl OrchestratorTask { return true; } - if self.impact_area.iter().any(|area| matches!(area, ImpactArea::Frontend)) { + if self + .impact_area + .iter() + .any(|area| matches!(area, ImpactArea::Frontend)) + { return true; } @@ -692,7 +739,10 @@ pub struct ListPageRequest { impl ListPageRequest { pub const fn unbounded() -> Self { - Self { limit: None, offset: 0 } + Self { + limit: None, + offset: 0, + } } pub fn bounds(self, total: usize) -> (usize, usize) { @@ -724,7 +774,15 @@ impl ListPage { let has_more = offset.saturating_add(returned) < total; let next_offset = has_more.then_some(offset.saturating_add(returned)); - Self { items, total, limit: request.limit, offset, returned, has_more, next_offset } + Self { + items, + total, + limit: request.limit, + offset, + returned, + has_more, + next_offset, + } } } @@ -848,7 +906,10 @@ pub struct ProjectModelPreferences { impl Default for ProjectModelPreferences { fn default() -> Self { Self { - allowed_models: crate::default_model_specs().into_iter().map(|(model_id, _tool)| model_id).collect(), + allowed_models: crate::default_model_specs() + .into_iter() + .map(|(model_id, _tool)| model_id) + .collect(), default_model: crate::default_model_for_tool("claude").map(str::to_string), phase_overrides: HashMap::new(), } @@ -863,7 +924,10 @@ pub struct ProjectConcurrencyLimits { impl Default for ProjectConcurrencyLimits { fn default() -> Self { - Self { max_workflows: 3, max_agents: 10 } + Self { + max_workflows: 3, + max_agents: 10, + } } } @@ -1338,6 +1402,13 @@ pub struct PhaseDecision { pub commit_message: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_phase: Option, + /// Custom routing key emitted by an agent or command phase when the + /// verdict string is not one of the built-in `advance`/`rework`/`skip`/ + /// `fail` values. Carries the raw verdict verbatim (e.g. `needs-research`) + /// so the workflow executor can route it through the phase's `on_verdict` + /// map to an arbitrary target phase. `None` for built-in verdicts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verdict_key: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1492,8 +1563,10 @@ pub struct OrchestratorWorkflow { pub id: String, pub task_id: String, pub workflow_ref: Option, - #[serde(default = "default_workflow_subject_ref")] - pub subject: SubjectRef, + /// The subject this workflow run targets, or `None` for a subjectless run. + /// Omitted from the wire when absent; older records always carry a subject. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] @@ -1519,10 +1592,6 @@ pub struct OrchestratorWorkflow { pub decision_history: Vec, } -fn default_workflow_subject_ref() -> SubjectRef { - SubjectRef::task(String::new()) -} - impl OrchestratorWorkflow { pub fn sync_status(&mut self) { self.status = self.machine_state.to_workflow_status(); @@ -1606,7 +1675,8 @@ const fn default_high_priority_budget_percent() -> u8 { } pub use animus_subject_protocol::{ - SubjectDispatch, SubjectRef, WorkflowSubject, SUBJECT_KIND_CUSTOM, SUBJECT_KIND_REQUIREMENT, SUBJECT_KIND_TASK, + SubjectDispatch, SubjectRef, WorkflowSubject, SUBJECT_KIND_CUSTOM, SUBJECT_KIND_REQUIREMENT, + SUBJECT_KIND_TASK, }; pub trait SubjectDispatchExt: Sized { @@ -1638,7 +1708,12 @@ pub trait SubjectDispatchExt: Sized { impl SubjectDispatchExt for SubjectDispatch { fn for_task(task_id: impl Into, workflow_ref: impl Into) -> Self { - ::for_task_with_metadata(task_id, workflow_ref, "ready-queue", Utc::now()) + ::for_task_with_metadata( + task_id, + workflow_ref, + "ready-queue", + Utc::now(), + ) } fn for_task_with_metadata( @@ -1685,19 +1760,27 @@ impl SubjectDispatchExt for SubjectDispatch { } fn to_workflow_run_input(&self) -> WorkflowRunInput { - let mut input = match self.subject.kind() { - SUBJECT_KIND_TASK => WorkflowRunInput::for_task(self.subject.id.clone(), Some(self.workflow_ref.clone())), - SUBJECT_KIND_REQUIREMENT => { - WorkflowRunInput::for_requirement(self.subject.id.clone(), Some(self.workflow_ref.clone())) - } - _ => WorkflowRunInput::for_custom( - self.subject.title.clone().unwrap_or_else(|| self.subject.id.clone()), - self.subject.description.clone().unwrap_or_default(), - Some(self.workflow_ref.clone()), - ), + let mut input = match self.subject.as_ref() { + None => WorkflowRunInput::subjectless(Some(self.workflow_ref.clone())), + Some(subject) => match subject.kind() { + SUBJECT_KIND_TASK => { + WorkflowRunInput::for_task(subject.id.clone(), Some(self.workflow_ref.clone())) + } + SUBJECT_KIND_REQUIREMENT => WorkflowRunInput::for_requirement( + subject.id.clone(), + Some(self.workflow_ref.clone()), + ), + _ => WorkflowRunInput::for_custom( + subject.title.clone().unwrap_or_else(|| subject.id.clone()), + subject.description.clone().unwrap_or_default(), + Some(self.workflow_ref.clone()), + ), + }, }; input.subject = self.subject.clone(); - input.with_input(self.input.clone()).with_vars(self.vars.clone()) + input + .with_input(self.input.clone()) + .with_vars(self.vars.clone()) } } @@ -1761,8 +1844,10 @@ impl SubjectExecutionFact { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowRunInput { - #[serde(default = "default_workflow_subject_ref")] - pub subject: SubjectRef, + /// The subject this run targets, or `None` for a subjectless run. Omitted + /// from the wire when absent; older payloads always carry a subject. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, #[serde(default)] pub workflow_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "input_json")] @@ -1785,7 +1870,31 @@ impl WorkflowRunInput { let requirement_id = subject.requirement_id().map(ToOwned::to_owned); let title = subject.title.clone(); let description = subject.description.clone(); - Self { subject, task_id, workflow_ref, input: None, vars: HashMap::new(), requirement_id, title, description } + Self { + subject: Some(subject), + task_id, + workflow_ref, + input: None, + vars: HashMap::new(), + requirement_id, + title, + description, + } + } + + /// Construct a subjectless run input (no bound subject). All subject-derived + /// fields are empty/absent so downstream subject template vars are absent. + pub fn subjectless(workflow_ref: Option) -> Self { + Self { + subject: None, + task_id: String::new(), + workflow_ref, + input: None, + vars: HashMap::new(), + requirement_id: None, + title: None, + description: None, + } } pub fn for_task(task_id: String, workflow_ref: Option) -> Self { @@ -1800,16 +1909,16 @@ impl WorkflowRunInput { Self::for_subject(SubjectRef::custom(title, description), workflow_ref) } - pub fn subject(&self) -> &SubjectRef { - &self.subject + pub fn subject(&self) -> Option<&SubjectRef> { + self.subject.as_ref() } - pub fn subject_id(&self) -> &str { - self.subject.id() + pub fn subject_id(&self) -> Option<&str> { + self.subject.as_ref().map(SubjectRef::id) } - pub fn subject_kind(&self) -> &str { - self.subject.kind() + pub fn subject_kind(&self) -> Option<&str> { + self.subject.as_ref().map(SubjectRef::kind) } pub fn workflow_ref(&self) -> Option<&str> { @@ -1839,25 +1948,48 @@ mod tests { #[test] fn list_page_request_bounds_clamp_to_available_items() { - let request = ListPageRequest { limit: Some(5), offset: 8 }; + let request = ListPageRequest { + limit: Some(5), + offset: 8, + }; assert_eq!(request.bounds(10), (8, 10)); - let request = ListPageRequest { limit: None, offset: 3 }; + let request = ListPageRequest { + limit: None, + offset: 3, + }; assert_eq!(request.bounds(10), (3, 10)); - let request = ListPageRequest { limit: Some(5), offset: 20 }; + let request = ListPageRequest { + limit: Some(5), + offset: 20, + }; assert_eq!(request.bounds(10), (10, 10)); } #[test] fn list_page_metadata_tracks_next_offset() { - let page = ListPage::new(vec![1, 2, 3], 10, ListPageRequest { limit: Some(3), offset: 3 }); + let page = ListPage::new( + vec![1, 2, 3], + 10, + ListPageRequest { + limit: Some(3), + offset: 3, + }, + ); assert_eq!(page.returned, 3); assert_eq!(page.offset, 3); assert!(page.has_more); assert_eq!(page.next_offset, Some(6)); - let final_page = ListPage::new(vec![7, 8], 8, ListPageRequest { limit: Some(3), offset: 6 }); + let final_page = ListPage::new( + vec![7, 8], + 8, + ListPageRequest { + limit: Some(3), + offset: 6, + }, + ); assert_eq!(final_page.returned, 2); assert!(!final_page.has_more); assert_eq!(final_page.next_offset, None); @@ -1875,7 +2007,10 @@ mod tests { linked_task_id: Some("TASK-590".to_string()), search_text: Some("query".to_string()), }, - page: ListPageRequest { limit: Some(25), offset: 50 }, + page: ListPageRequest { + limit: Some(25), + offset: 50, + }, sort: RequirementQuerySort::UpdatedAt, }; @@ -1891,13 +2026,15 @@ mod tests { sort: WorkflowQuerySort::Status, }; - let requirement_value = serde_json::to_value(&requirement_query).expect("requirement query should serialize"); + let requirement_value = + serde_json::to_value(&requirement_query).expect("requirement query should serialize"); assert_eq!(requirement_value["filter"]["status"], "in-progress"); assert_eq!(requirement_value["filter"]["priority"], "must"); assert_eq!(requirement_value["filter"]["type"], "technical"); assert_eq!(requirement_value["sort"], "updated_at"); - let workflow_value = serde_json::to_value(&workflow_query).expect("workflow query should serialize"); + let workflow_value = + serde_json::to_value(&workflow_query).expect("workflow query should serialize"); assert_eq!(workflow_value["filter"]["status"], "running"); assert_eq!(workflow_value["filter"]["workflow_ref"], "standard"); assert_eq!(workflow_value["sort"], "status"); @@ -1913,7 +2050,14 @@ mod tests { #[test] fn list_page_handles_zero_total() { - let page: ListPage = ListPage::new(Vec::new(), 0, ListPageRequest { limit: Some(10), offset: 5 }); + let page: ListPage = ListPage::new( + Vec::new(), + 0, + ListPageRequest { + limit: Some(10), + offset: 5, + }, + ); assert_eq!(page.offset, 0); assert_eq!(page.returned, 0); assert!(!page.has_more); @@ -1965,12 +2109,19 @@ mod tests { Utc.with_ymd_and_hms(2026, 3, 10, 9, 30, 0).unwrap(), ); - let serialized = serde_json::to_value(&dispatch).expect("generic dispatch should serialize"); + let serialized = + serde_json::to_value(&dispatch).expect("generic dispatch should serialize"); assert_eq!(serialized["subject"]["kind"], "pack.review"); assert_eq!(serialized["subject"]["id"], "REV-1"); - assert_eq!(dispatch.subject_kind(), "pack.review"); - assert_eq!(dispatch.subject_key(), "pack.review::REV-1"); - assert_eq!(dispatch.to_workflow_run_input().subject(), &SubjectRef::new("pack.review", "REV-1")); + assert_eq!(dispatch.subject_kind(), Some("pack.review")); + assert_eq!( + dispatch.subject_key().as_deref(), + Some("pack.review::REV-1") + ); + assert_eq!( + dispatch.to_workflow_run_input().subject(), + Some(&SubjectRef::new("pack.review", "REV-1")) + ); } #[test] @@ -1987,9 +2138,42 @@ mod tests { })) .expect("legacy requirement dispatch should deserialize"); - assert_eq!(dispatch.subject_kind(), SUBJECT_KIND_REQUIREMENT); - assert_eq!(dispatch.subject_id(), "REQ-39"); - assert_eq!(dispatch.subject_key(), "REQ-39"); + assert_eq!(dispatch.subject_kind(), Some(SUBJECT_KIND_REQUIREMENT)); + assert_eq!(dispatch.subject_id(), Some("REQ-39")); + assert_eq!(dispatch.subject_key().as_deref(), Some("REQ-39")); + } + + #[test] + fn subjectless_dispatch_maps_to_subjectless_run_input() { + let dispatch = SubjectDispatch::subjectless( + "relate", + "manual", + Utc.with_ymd_and_hms(2026, 3, 10, 9, 30, 0).unwrap(), + ); + assert!(dispatch.subject().is_none()); + assert_eq!(dispatch.subject_id(), None); + + // Subjectless dispatch omits `subject` from the wire entirely. + let serialized = serde_json::to_value(&dispatch).expect("subjectless dispatch serializes"); + assert!( + serialized.get("subject").is_none(), + "subject must be absent: {serialized}" + ); + + // ... and lowers to a subjectless run input (all subject-derived fields + // empty/absent). + let input = dispatch.to_workflow_run_input(); + assert!(input.subject().is_none()); + assert_eq!(input.subject_id(), None); + assert!(input.task_id.is_empty()); + assert_eq!(input.requirement_id, None); + assert_eq!(input.workflow_ref(), Some("relate")); + + let input_value = serde_json::to_value(&input).expect("subjectless run input serializes"); + assert!( + input_value.get("subject").is_none(), + "run input subject must be absent: {input_value}" + ); } #[test] @@ -2018,6 +2202,9 @@ mod tests { health.paused_at = Some("2026-06-11T00:00:00+00:00".to_string()); let value = serde_json::to_value(&health).expect("serialize"); assert_eq!(value["runtime_paused"], serde_json::json!(true)); - assert_eq!(value["paused_at"], serde_json::json!("2026-06-11T00:00:00+00:00")); + assert_eq!( + value["paused_at"], + serde_json::json!("2026-06-11T00:00:00+00:00") + ); } } diff --git a/protocol/src/process.rs b/protocol/src/process.rs index 72b5e7f..b5c6b7e 100644 --- a/protocol/src/process.rs +++ b/protocol/src/process.rs @@ -17,7 +17,12 @@ fn windows_process_exists(pid: i32) -> bool { .output() .ok() .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).lines().map(str::trim).any(|line| line.starts_with('"'))) + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .any(|line| line.starts_with('"')) + }) .unwrap_or(false) } @@ -30,7 +35,9 @@ fn taskkill_process_tree(pid: u32, force: bool) -> Result<()> { command.arg("/F"); } - let output = command.output().with_context(|| format!("failed to launch taskkill for process {pid}"))?; + let output = command + .output() + .with_context(|| format!("failed to launch taskkill for process {pid}"))?; if output.status.success() || !is_process_alive(pid) { return Ok(()); @@ -39,12 +46,18 @@ fn taskkill_process_tree(pid: u32, force: bool) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let detail = stderr.trim(); - let detail = if detail.is_empty() { stdout.trim() } else { detail }; + let detail = if detail.is_empty() { + stdout.trim() + } else { + detail + }; if detail.is_empty() { Err(anyhow::anyhow!("taskkill failed for process {pid}")) } else { - Err(anyhow::anyhow!("taskkill failed for process {pid}: {detail}")) + Err(anyhow::anyhow!( + "taskkill failed for process {pid}: {detail}" + )) } } @@ -82,7 +95,9 @@ pub fn is_process_alive(pid: u32) -> bool { #[cfg(unix)] fn is_zombie_process(pid: i32) -> bool { - let output = Command::new("ps").args(["-o", "state=", "-p", &pid.to_string()]).output(); + let output = Command::new("ps") + .args(["-o", "state=", "-p", &pid.to_string()]) + .output(); match output { Ok(out) => { let state = String::from_utf8_lossy(&out.stdout); diff --git a/protocol/src/repository_scope.rs b/protocol/src/repository_scope.rs index 4b7fe8b..6807b6d 100644 --- a/protocol/src/repository_scope.rs +++ b/protocol/src/repository_scope.rs @@ -42,7 +42,9 @@ fn reclaim_scope_marker_if_foreign(scope_dir: &Path, project_root: &Path) { persist_project_root_marker(scope_dir, project_root); return; }; - let canonical = project_root.canonicalize().unwrap_or_else(|_| project_root.to_path_buf()); + let canonical = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); let recorded = Path::new(recorded_raw.trim()); match recorded.canonicalize() { Ok(recorded_canonical) if paths_refer_to_same_file(&recorded_canonical, &canonical) => {} @@ -62,8 +64,13 @@ fn reclaim_scope_marker_if_foreign(scope_dir: &Path, project_root: &Path) { } fn persist_project_root_marker(scope_dir: &Path, project_root: &Path) { - let canonical = project_root.canonicalize().unwrap_or_else(|_| project_root.to_path_buf()); - let _ = std::fs::write(scope_dir.join(".project-root"), format!("{}\n", canonical.to_string_lossy())); + let canonical = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let _ = std::fs::write( + scope_dir.join(".project-root"), + format!("{}\n", canonical.to_string_lossy()), + ); } fn git_remote_origin(project_root: &Path) -> Option { @@ -81,7 +88,9 @@ fn git_remote_origin(project_root: &Path) -> Option { fn find_existing_scope_by_origin(ao_root: &Path, project_root: &Path) -> Option { let our_origin = git_remote_origin(project_root)?; - let canonical = project_root.canonicalize().unwrap_or_else(|_| project_root.to_path_buf()); + let canonical = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); let entries = std::fs::read_dir(ao_root).ok()?; for entry in entries.flatten() { @@ -123,7 +132,9 @@ fn find_existing_scope_by_origin(ao_root: &Path, project_root: &Path) -> Option< // canonical string preserves caller casing, so the same // directory reached via differently-cased paths must // still adopt this scope instead of splitting. - Ok(existing_canonical) if paths_refer_to_same_file(&existing_canonical, &canonical) => { + Ok(existing_canonical) + if paths_refer_to_same_file(&existing_canonical, &canonical) => + { return Some(scope_dir); } Ok(_) => { @@ -286,7 +297,10 @@ mod tests { assert_eq!(sanitize_identifier("Repo Name", "repo"), "repo-name"); assert_eq!(sanitize_identifier("___", "repo"), "repo"); assert_eq!(sanitize_identifier("A__B--C", "repo"), "a-b-c"); - assert_eq!(sanitize_identifier(" __My Repo!! -- 2026__ ", "repo"), "my-repo-2026"); + assert_eq!( + sanitize_identifier(" __My Repo!! -- 2026__ ", "repo"), + "my-repo-2026" + ); assert_eq!(sanitize_identifier("日本語", "repo"), "repo"); assert_eq!(sanitize_identifier("日本語", "task"), "task"); } @@ -345,9 +359,14 @@ mod tests { use std::os::unix::fs::PermissionsExt; let git_script = bin.join("git"); - std::fs::write(&git_script, format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display())) - .expect("write fake git"); - let mut perms = std::fs::metadata(&git_script).expect("metadata").permissions(); + std::fs::write( + &git_script, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + ) + .expect("write fake git"); + let mut perms = std::fs::metadata(&git_script) + .expect("metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&git_script, perms).expect("set perms"); } @@ -357,7 +376,10 @@ mod tests { let resolved = scoped_state_root(&repo).expect("scope dir"); assert_eq!(resolved, scope_dir); - assert!(!marker.exists(), "existing scope lookup should not invoke git"); + assert!( + !marker.exists(), + "existing scope lookup should not invoke git" + ); } #[cfg(unix)] @@ -377,9 +399,14 @@ mod tests { // Fake git that always reports the same origin URL regardless of cwd. let git_script = bin.join("git"); - std::fs::write(&git_script, "#!/bin/sh\necho 'git@github.com:example/shared-repo.git'\n") - .expect("write fake git"); - let mut perms = std::fs::metadata(&git_script).expect("metadata").permissions(); + std::fs::write( + &git_script, + "#!/bin/sh\necho 'git@github.com:example/shared-repo.git'\n", + ) + .expect("write fake git"); + let mut perms = std::fs::metadata(&git_script) + .expect("metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&git_script, perms).expect("set perms"); @@ -389,12 +416,25 @@ mod tests { let scope_a = scoped_state_root(&clone_a).expect("scope a"); let scope_b = scoped_state_root(&clone_b).expect("scope b"); - let expected_a = home.join(".animus").join(repository_scope_for_path(&clone_a)); - let expected_b = home.join(".animus").join(repository_scope_for_path(&clone_b)); - - assert_eq!(scope_a, expected_a, "clone A should land on its hash-derived scope"); - assert_eq!(scope_b, expected_b, "clone B should land on its hash-derived scope"); - assert_ne!(scope_a, scope_b, "two clones of the same origin must not share a scope"); + let expected_a = home + .join(".animus") + .join(repository_scope_for_path(&clone_a)); + let expected_b = home + .join(".animus") + .join(repository_scope_for_path(&clone_b)); + + assert_eq!( + scope_a, expected_a, + "clone A should land on its hash-derived scope" + ); + assert_eq!( + scope_b, expected_b, + "clone B should land on its hash-derived scope" + ); + assert_ne!( + scope_a, scope_b, + "two clones of the same origin must not share a scope" + ); // Subsequent calls must remain stable and not cross over via the // same-origin fallback. @@ -441,9 +481,14 @@ mod tests { std::fs::create_dir_all(&bin).expect("bin dir"); let git_script = bin.join("git"); - std::fs::write(&git_script, "#!/bin/sh\necho 'git@github.com:example/cased-repo.git'\n") - .expect("write fake git"); - let mut perms = std::fs::metadata(&git_script).expect("metadata").permissions(); + std::fs::write( + &git_script, + "#!/bin/sh\necho 'git@github.com:example/cased-repo.git'\n", + ) + .expect("write fake git"); + let mut perms = std::fs::metadata(&git_script) + .expect("metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&git_script, perms).expect("set perms"); @@ -459,7 +504,10 @@ mod tests { return; } let adopted = scoped_state_root(&lowercased).expect("adopted scope"); - assert_eq!(adopted, original_scope, "differently-cased access must adopt the existing scope, not split"); + assert_eq!( + adopted, original_scope, + "differently-cased access must adopt the existing scope, not split" + ); } #[cfg(unix)] @@ -476,25 +524,39 @@ mod tests { std::fs::create_dir_all(&bin).expect("bin dir"); let git_script = bin.join("git"); - std::fs::write(&git_script, "#!/bin/sh\necho 'git@github.com:example/moved-repo.git'\n") - .expect("write fake git"); - let mut perms = std::fs::metadata(&git_script).expect("metadata").permissions(); + std::fs::write( + &git_script, + "#!/bin/sh\necho 'git@github.com:example/moved-repo.git'\n", + ) + .expect("write fake git"); + let mut perms = std::fs::metadata(&git_script) + .expect("metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&git_script, perms).expect("set perms"); // Pre-create a legacy scope whose recorded `.project-root` no longer exists. let legacy_scope = home.join(".animus").join("legacy-scope-aaaaaaaaaaaa"); std::fs::create_dir_all(&legacy_scope).expect("legacy scope"); - std::fs::write(legacy_scope.join(".git-origin"), "git@github.com:example/moved-repo.git\n") - .expect("write origin"); - std::fs::write(legacy_scope.join(".project-root"), format!("{}\n", temp.path().join("old-location").display())) - .expect("write stale project-root"); + std::fs::write( + legacy_scope.join(".git-origin"), + "git@github.com:example/moved-repo.git\n", + ) + .expect("write origin"); + std::fs::write( + legacy_scope.join(".project-root"), + format!("{}\n", temp.path().join("old-location").display()), + ) + .expect("write stale project-root"); let _home_guard = EnvVarGuard::set("HOME", Some(home.to_string_lossy().as_ref())); let _path_guard = EnvVarGuard::set("PATH", Some(bin.to_string_lossy().as_ref())); let resolved = scoped_state_root(&new_clone).expect("scope"); - assert_eq!(resolved, legacy_scope, "moved clone should reclaim its legacy scope"); + assert_eq!( + resolved, legacy_scope, + "moved clone should reclaim its legacy scope" + ); // And the marker should now point at the new canonical location. let marker = std::fs::read_to_string(legacy_scope.join(".project-root")).expect("marker"); @@ -504,11 +566,19 @@ mod tests { #[test] fn recorded_path_transience_distinguishes_missing_mount_from_missing_leaf() { - assert!(recorded_path_is_transiently_unavailable(Path::new("/Volumes/animus-no-such-volume-1f2e3d/repo"))); - assert!(recorded_path_is_transiently_unavailable(Path::new("/mnt/animus-no-such-volume-1f2e3d/repo"))); + assert!(recorded_path_is_transiently_unavailable(Path::new( + "/Volumes/animus-no-such-volume-1f2e3d/repo" + ))); + assert!(recorded_path_is_transiently_unavailable(Path::new( + "/mnt/animus-no-such-volume-1f2e3d/repo" + ))); // Repo mounted directly at the volume root. - assert!(recorded_path_is_transiently_unavailable(Path::new("/mnt/animus-no-such-volume-1f2e3d"))); - assert!(recorded_path_is_transiently_unavailable(Path::new("/animus-no-such-root-1f2e3d/nested/repo"))); + assert!(recorded_path_is_transiently_unavailable(Path::new( + "/mnt/animus-no-such-volume-1f2e3d" + ))); + assert!(recorded_path_is_transiently_unavailable(Path::new( + "/animus-no-such-root-1f2e3d/nested/repo" + ))); // Parent chain present, leaf gone → the repo was moved or deleted. let temp = tempdir().expect("tempdir"); @@ -530,9 +600,14 @@ mod tests { std::fs::create_dir_all(&bin).expect("bin dir"); let git_script = bin.join("git"); - std::fs::write(&git_script, "#!/bin/sh\necho 'git@github.com:example/unmounted-repo.git'\n") - .expect("write fake git"); - let mut perms = std::fs::metadata(&git_script).expect("metadata").permissions(); + std::fs::write( + &git_script, + "#!/bin/sh\necho 'git@github.com:example/unmounted-repo.git'\n", + ) + .expect("write fake git"); + let mut perms = std::fs::metadata(&git_script) + .expect("metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&git_script, perms).expect("set perms"); @@ -540,16 +615,28 @@ mod tests { let unmounted_root = "/Volumes/animus-test-absent-volume-9a8b7c/repo"; let offline_scope = home.join(".animus").join("offline-scope-bbbbbbbbbbbb"); std::fs::create_dir_all(&offline_scope).expect("offline scope"); - std::fs::write(offline_scope.join(".git-origin"), "git@github.com:example/unmounted-repo.git\n") - .expect("write origin"); - std::fs::write(offline_scope.join(".project-root"), format!("{unmounted_root}\n")).expect("write project-root"); + std::fs::write( + offline_scope.join(".git-origin"), + "git@github.com:example/unmounted-repo.git\n", + ) + .expect("write origin"); + std::fs::write( + offline_scope.join(".project-root"), + format!("{unmounted_root}\n"), + ) + .expect("write project-root"); let _home_guard = EnvVarGuard::set("HOME", Some(home.to_string_lossy().as_ref())); let _path_guard = EnvVarGuard::set("PATH", Some(bin.to_string_lossy().as_ref())); let resolved = scoped_state_root(&new_clone).expect("scope"); - let expected = home.join(".animus").join(repository_scope_for_path(&new_clone)); - assert_eq!(resolved, expected, "clone must not adopt a scope whose owner is merely unmounted"); + let expected = home + .join(".animus") + .join(repository_scope_for_path(&new_clone)); + assert_eq!( + resolved, expected, + "clone must not adopt a scope whose owner is merely unmounted" + ); assert_ne!(resolved, offline_scope); // The offline clone's marker must survive untouched for remount. @@ -595,11 +682,16 @@ mod tests { // Our hash-derived scope exists but its marker was rewritten by a // sibling clone while our path was unreachable. - let scope_dir = home.join(".animus").join(repository_scope_for_path(&our_clone)); + let scope_dir = home + .join(".animus") + .join(repository_scope_for_path(&our_clone)); std::fs::create_dir_all(&scope_dir).expect("scope dir"); let canonical_other = other_clone.canonicalize().expect("canon other"); - std::fs::write(scope_dir.join(".project-root"), format!("{}\n", canonical_other.display())) - .expect("write foreign marker"); + std::fs::write( + scope_dir.join(".project-root"), + format!("{}\n", canonical_other.display()), + ) + .expect("write foreign marker"); let _home_guard = EnvVarGuard::set("HOME", Some(home.to_string_lossy().as_ref())); @@ -610,7 +702,9 @@ mod tests { .with_ansi(false) .finish(); - let resolved = tracing::subscriber::with_default(subscriber, || scoped_state_root(&our_clone).expect("scope")); + let resolved = tracing::subscriber::with_default(subscriber, || { + scoped_state_root(&our_clone).expect("scope") + }); assert_eq!(resolved, scope_dir); // The marker must be reclaimed for the caller whose path hashes to @@ -619,10 +713,20 @@ mod tests { let canonical_ours = our_clone.canonicalize().expect("canon ours"); assert_eq!(marker.trim(), canonical_ours.to_string_lossy()); - let logs = String::from_utf8(capture.0.lock().expect("capture lock").clone()).expect("utf8 logs"); - assert!(logs.contains("reclaiming the scope"), "expected a warn about reclaiming, got: {logs}"); - assert!(logs.contains(canonical_other.to_string_lossy().as_ref()), "warn should name the recorded path"); - assert!(logs.contains(canonical_ours.to_string_lossy().as_ref()), "warn should name the current path"); + let logs = + String::from_utf8(capture.0.lock().expect("capture lock").clone()).expect("utf8 logs"); + assert!( + logs.contains("reclaiming the scope"), + "expected a warn about reclaiming, got: {logs}" + ); + assert!( + logs.contains(canonical_other.to_string_lossy().as_ref()), + "warn should name the recorded path" + ); + assert!( + logs.contains(canonical_ours.to_string_lossy().as_ref()), + "warn should name the current path" + ); } #[cfg(unix)] diff --git a/protocol/src/sync_config.rs b/protocol/src/sync_config.rs index fd2d7c8..7b47303 100644 --- a/protocol/src/sync_config.rs +++ b/protocol/src/sync_config.rs @@ -18,7 +18,9 @@ impl SyncConfig { } pub fn load_for_project(project_root: &str) -> Self { - let project_path = PathBuf::from(project_root).join(".animus").join("sync.json"); + let project_path = PathBuf::from(project_root) + .join(".animus") + .join("sync.json"); if let Some(project_config) = Self::try_load_from(&project_path) { return project_config.merge_with_global(); } @@ -36,7 +38,9 @@ impl SyncConfig { } pub fn save_for_project(&self, project_root: &str) -> anyhow::Result<()> { - let path = PathBuf::from(project_root).join(".animus").join("sync.json"); + let path = PathBuf::from(project_root) + .join(".animus") + .join("sync.json"); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -51,7 +55,9 @@ impl SyncConfig { server: self.server.or(global.server), token: self.token.or(global.token), refresh_token: self.refresh_token.or(global.refresh_token), - access_token_expires_at: self.access_token_expires_at.or(global.access_token_expires_at), + access_token_expires_at: self + .access_token_expires_at + .or(global.access_token_expires_at), project_id: self.project_id.or(global.project_id), last_synced_at: self.last_synced_at.or(global.last_synced_at), } @@ -75,13 +81,17 @@ impl SyncConfig { pub fn server_url(&self) -> anyhow::Result { self.server.clone().ok_or_else(|| { - anyhow::anyhow!("Sync server not configured. Run: animus sync setup --server --token ") + anyhow::anyhow!( + "Sync server not configured. Run: animus sync setup --server --token " + ) }) } pub fn bearer_token(&self) -> anyhow::Result { self.token.clone().ok_or_else(|| { - anyhow::anyhow!("Sync token not configured. Run: animus sync setup --server --token ") + anyhow::anyhow!( + "Sync token not configured. Run: animus sync setup --server --token " + ) }) } @@ -89,7 +99,8 @@ impl SyncConfig { if let Some(ref expires_at) = self.access_token_expires_at { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_at) { let now = chrono::Utc::now(); - let refresh_threshold = expires.with_timezone(&chrono::Utc) - chrono::Duration::minutes(5); + let refresh_threshold = + expires.with_timezone(&chrono::Utc) - chrono::Duration::minutes(5); return now >= refresh_threshold; } } @@ -177,9 +188,18 @@ mod tests { assert_eq!(deserialized.server, Some("http://example.com".to_string())); assert_eq!(deserialized.token, Some("access_token".to_string())); - assert_eq!(deserialized.refresh_token, Some("refresh_token".to_string())); - assert_eq!(deserialized.access_token_expires_at, Some("2025-12-31T23:59:59Z".to_string())); + assert_eq!( + deserialized.refresh_token, + Some("refresh_token".to_string()) + ); + assert_eq!( + deserialized.access_token_expires_at, + Some("2025-12-31T23:59:59Z".to_string()) + ); assert_eq!(deserialized.project_id, Some("project-123".to_string())); - assert_eq!(deserialized.last_synced_at, Some("2025-01-01T00:00:00Z".to_string())); + assert_eq!( + deserialized.last_synced_at, + Some("2025-01-01T00:00:00Z".to_string()) + ); } } diff --git a/protocol/src/test_utils.rs b/protocol/src/test_utils.rs index 8067b6c..3dbeff0 100644 --- a/protocol/src/test_utils.rs +++ b/protocol/src/test_utils.rs @@ -36,7 +36,11 @@ impl EnvVarGuard { Some(value) => std::env::set_var(key, value), None => std::env::remove_var(key), } - Self { key: key.to_string(), previous, _lock: lock } + Self { + key: key.to_string(), + previous, + _lock: lock, + } } } diff --git a/protocol/tests/agent_runner.rs b/protocol/tests/agent_runner.rs index 6ccb2b7..824d36e 100644 --- a/protocol/tests/agent_runner.rs +++ b/protocol/tests/agent_runner.rs @@ -3,9 +3,14 @@ use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_config_dir(label: &str) -> std::path::PathBuf { - let nanos = - SystemTime::now().duration_since(UNIX_EPOCH).expect("system time should be after unix epoch").as_nanos(); - let dir = std::env::temp_dir().join(format!("protocol-config-{label}-{}-{nanos}", std::process::id())); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "protocol-config-{label}-{}-{nanos}", + std::process::id() + )); std::fs::create_dir_all(&dir).expect("create temp config dir"); dir } @@ -39,7 +44,10 @@ fn test_agent_run_event_serialization() { #[test] fn test_agent_control_request() { - let req = AgentControlRequest { run_id: RunId("run-456".into()), action: AgentControlAction::Pause }; + let req = AgentControlRequest { + run_id: RunId("run-456".into()), + action: AgentControlAction::Pause, + }; let json = serde_json::to_string(&req).unwrap(); let parsed: AgentControlRequest = serde_json::from_str(&json).unwrap(); @@ -73,7 +81,8 @@ fn test_agent_status_query_response_status_roundtrip() { }); let json = serde_json::to_string(&response).expect("serialize status query response"); - let parsed: AgentStatusQueryResponse = serde_json::from_str(&json).expect("deserialize status query response"); + let parsed: AgentStatusQueryResponse = + serde_json::from_str(&json).expect("deserialize status query response"); match parsed { AgentStatusQueryResponse::Status(status) => { @@ -95,7 +104,8 @@ fn test_agent_status_query_response_not_found_roundtrip() { }); let json = serde_json::to_string(&response).expect("serialize error query response"); - let parsed: AgentStatusQueryResponse = serde_json::from_str(&json).expect("deserialize error query response"); + let parsed: AgentStatusQueryResponse = + serde_json::from_str(&json).expect("deserialize error query response"); match parsed { AgentStatusQueryResponse::Error(error) => { @@ -123,7 +133,10 @@ fn test_model_availability_enum() { fn test_project_model_config() { let config = ProjectModelConfig { project_id: ProjectId("proj-123".into()), - allowed_models: vec![ModelId("claude-sonnet-4".into()), ModelId("gpt-4-turbo".into())], + allowed_models: vec![ + ModelId("claude-sonnet-4".into()), + ModelId("gpt-4-turbo".into()), + ], phase_defaults: WorkflowPhaseModelDefaults { design: Some(ModelId("gemini-3-pro".into())), development: Some(ModelId("claude-sonnet-4".into())), @@ -142,8 +155,13 @@ fn test_project_model_config() { #[test] fn test_runner_status_request_rejects_unexpected_fields() { - let parsed = serde_json::from_str::(r#"{"run_id":"run-cli-control","action":"terminate"}"#); - assert!(parsed.is_err(), "runner status request must reject control-shaped payloads"); + let parsed = serde_json::from_str::( + r#"{"run_id":"run-cli-control","action":"terminate"}"#, + ); + assert!( + parsed.is_err(), + "runner status request must reject control-shaped payloads" + ); } #[test] @@ -156,7 +174,8 @@ fn test_runner_status_response_roundtrip_includes_protocol_metadata() { }; let json = serde_json::to_string(&response).expect("serialize runner status"); - let parsed: RunnerStatusResponse = serde_json::from_str(&json).expect("deserialize runner status"); + let parsed: RunnerStatusResponse = + serde_json::from_str(&json).expect("deserialize runner status"); assert_eq!(parsed.active_agents, 2); assert_eq!(parsed.protocol_version, PROTOCOL_VERSION); @@ -186,15 +205,23 @@ fn test_ipc_auth_request_roundtrip() { #[test] fn test_ipc_auth_request_rejects_unknown_fields() { - let parsed = serde_json::from_str::(r#"{"kind":"ipc_auth","token":"secret","extra":"value"}"#); - assert!(parsed.is_err(), "auth request must reject unknown fields to keep handshake strict"); + let parsed = serde_json::from_str::( + r#"{"kind":"ipc_auth","token":"secret","extra":"value"}"#, + ); + assert!( + parsed.is_err(), + "auth request must reject unknown fields to keep handshake strict" + ); } #[test] fn test_ipc_auth_result_failure_roundtrip() { let result = IpcAuthResult::rejected(IpcAuthFailureCode::InvalidToken, "unauthorized"); let json = serde_json::to_string(&result).expect("serialize auth failure"); - assert_eq!(json, r#"{"kind":"ipc_auth_result","ok":false,"code":"invalid_token","message":"unauthorized"}"#); + assert_eq!( + json, + r#"{"kind":"ipc_auth_result","ok":false,"code":"invalid_token","message":"unauthorized"}"# + ); let parsed: IpcAuthResult = serde_json::from_str(&json).expect("deserialize auth failure"); assert!(!parsed.ok); @@ -230,8 +257,13 @@ fn test_config_get_token_rejects_blank_config_value() { secrets: None, }; - let error = config.get_token().expect_err("blank config token should fail closed"); - assert!(error.to_string().contains("agent_runner_token"), "error should mention config token source"); + let error = config + .get_token() + .expect_err("blank config token should fail closed"); + assert!( + error.to_string().contains("agent_runner_token"), + "error should mention config token source" + ); } #[test] @@ -246,8 +278,13 @@ fn test_config_get_token_rejects_missing_token() { secrets: None, }; - let error = config.get_token().expect_err("missing token should fail closed"); - assert!(error.to_string().contains("agent_runner_token"), "error should mention missing config token"); + let error = config + .get_token() + .expect_err("missing token should fail closed"); + assert!( + error.to_string().contains("agent_runner_token"), + "error should mention missing config token" + ); } #[test] @@ -258,7 +295,10 @@ fn test_config_load_from_dir_creates_default_config_file() { let loaded = Config::load_from_dir(&config_dir).expect("load scoped config"); assert!(loaded.agent_runner_token.is_none()); - assert!(config_path.exists(), "loading from a fresh directory should create config.json"); + assert!( + config_path.exists(), + "loading from a fresh directory should create config.json" + ); let _ = std::fs::remove_dir_all(config_dir); } @@ -292,7 +332,10 @@ fn test_ensure_token_exists_generates_token_when_missing() { let loaded = Config::load_from_dir(&config_dir).expect("reload config"); let token = loaded.agent_runner_token.expect("token should be set"); assert!(!token.is_empty(), "token should not be empty"); - assert!(uuid::Uuid::parse_str(&token).is_ok(), "token should be a valid UUID"); + assert!( + uuid::Uuid::parse_str(&token).is_ok(), + "token should be a valid UUID" + ); let _ = std::fs::remove_dir_all(config_dir); } @@ -306,7 +349,11 @@ fn test_ensure_token_exists_preserves_existing_token() { Config::ensure_token_exists(&config_dir).expect("ensure_token_exists should succeed"); let loaded = Config::load_from_dir(&config_dir).expect("reload config"); - assert_eq!(loaded.agent_runner_token.as_deref(), Some("keep-me"), "existing token should be preserved"); + assert_eq!( + loaded.agent_runner_token.as_deref(), + Some("keep-me"), + "existing token should be preserved" + ); let _ = std::fs::remove_dir_all(config_dir); } diff --git a/protocol/tests/compat_serialization.rs b/protocol/tests/compat_serialization.rs index 6759435..06063d7 100644 --- a/protocol/tests/compat_serialization.rs +++ b/protocol/tests/compat_serialization.rs @@ -1,6 +1,7 @@ use protocol::{ - AgentControlAction, AgentControlRequest, AgentRunEvent, AgentRunRequest, ModelId, OutputStreamType, - RequirementPriority, RunId, Timestamp, TokenUsage, ToolCallInfo, PROTOCOL_VERSION, + AgentControlAction, AgentControlRequest, AgentRunEvent, AgentRunRequest, ModelId, + OutputStreamType, RequirementPriority, RunId, Timestamp, TokenUsage, ToolCallInfo, + PROTOCOL_VERSION, }; use serde_json::json; @@ -56,7 +57,13 @@ fn metadata_tokens_shape_is_stable() { let event = AgentRunEvent::Metadata { run_id: RunId("run-123".to_string()), cost: Some(0.12), - tokens: Some(TokenUsage { input: 120, output: 48, reasoning: Some(5), cache_read: Some(2), cache_write: None }), + tokens: Some(TokenUsage { + input: 120, + output: 48, + reasoning: Some(5), + cache_read: Some(2), + cache_write: None, + }), }; let value = serde_json::to_value(event).expect("serialize metadata event"); @@ -86,28 +93,36 @@ fn tool_call_event_serialization_shape_is_stable() { assert_eq!(value["run_id"], "run-123"); assert_eq!(value["tool_info"]["tool_name"], "search_query"); assert_eq!(value["tool_info"]["parameters"]["q"], "rust serde"); - assert!(value["tool_info"]["timestamp"].is_string(), "tool_call timestamp must remain string-encoded"); + assert!( + value["tool_info"]["timestamp"].is_string(), + "tool_call timestamp must remain string-encoded" + ); } #[test] fn control_request_roundtrip_shape_is_stable() { - let request = - AgentControlRequest { run_id: RunId("run-control-1".to_string()), action: AgentControlAction::Terminate }; + let request = AgentControlRequest { + run_id: RunId("run-control-1".to_string()), + action: AgentControlAction::Terminate, + }; let value = serde_json::to_value(&request).expect("serialize control request"); assert_eq!(value["run_id"], "run-control-1"); assert_eq!(value["action"], "terminate"); - let decoded: AgentControlRequest = serde_json::from_value(value).expect("deserialize control request"); + let decoded: AgentControlRequest = + serde_json::from_value(value).expect("deserialize control request"); assert_eq!(decoded.run_id.0, "run-control-1"); assert_eq!(decoded.action, AgentControlAction::Terminate); } #[test] fn requirement_priority_serialization_is_lowercase_and_stable() { - let value = serde_json::to_value(RequirementPriority::Must).expect("serialize requirement priority"); + let value = + serde_json::to_value(RequirementPriority::Must).expect("serialize requirement priority"); assert_eq!(value, json!("must")); - let decoded: RequirementPriority = serde_json::from_value(json!("wont")).expect("deserialize requirement priority"); + let decoded: RequirementPriority = + serde_json::from_value(json!("wont")).expect("deserialize requirement priority"); assert_eq!(decoded, RequirementPriority::Wont); } diff --git a/schemas/animus-application-protocol/AllowedAction.json b/schemas/animus-application-protocol/AllowedAction.json new file mode 100644 index 0000000..734bfe5 --- /dev/null +++ b/schemas/animus-application-protocol/AllowedAction.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AllowedAction", + "description": "Portal-derived action a client may render for a projected resource.\n\nThis enum standardizes the wire vocabulary; it does not grant an action.\nThe authenticated application boundary remains the authority that derives\nthe array for each caller and resource.", + "oneOf": [ + { + "description": "Read the resource projection.", + "type": "string", + "const": "read" + }, + { + "description": "Inspect an agent's safe configuration projection.", + "type": "string", + "const": "inspect" + }, + { + "description": "Launch a workflow run from the resource.", + "type": "string", + "const": "launch" + }, + { + "description": "Mint or consume a stream grant for a run.", + "type": "string", + "const": "stream" + }, + { + "description": "Send a message to a conversation.", + "type": "string", + "const": "send" + }, + { + "description": "Respond to an interaction.", + "type": "string", + "const": "respond" + } + ] +} diff --git a/schemas/animus-application-protocol/AllowedApplicationChatControls.json b/schemas/animus-application-protocol/AllowedApplicationChatControls.json new file mode 100644 index 0000000..f71775c --- /dev/null +++ b/schemas/animus-application-protocol/AllowedApplicationChatControls.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AllowedApplicationChatControls", + "description": "Allowed application control values projected by an authenticated portal.\n\nThis is a transport shape only. The portal decides which values appear.", + "type": "object", + "properties": { + "approvals": { + "description": "Allowed approval selections.", + "type": "array", + "default": [], + "items": { + "type": "boolean" + } + }, + "permission_intent": { + "description": "Allowed permission intent selections.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/ApplicationPermissionIntent" + } + }, + "profile_ref": { + "description": "Allowed canonical profile assertion.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/ApplicationConfiguredRef" + } + }, + "reasoning_effort": { + "description": "Allowed reasoning effort selections.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/ApplicationReasoningEffort" + } + }, + "schema": { + "description": "Exact controls schema discriminator.", + "$ref": "#/$defs/ApplicationChatControlsSchema" + }, + "skill_ref": { + "description": "Allowed configured skill references.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/ApplicationConfiguredRef" + } + } + }, + "additionalProperties": false, + "required": [ + "schema" + ], + "$defs": { + "ApplicationChatControlsSchema": { + "description": "Stable schema discriminator for [`ApplicationChatControls`].", + "oneOf": [ + { + "description": "`animus.chat.application_controls.v1`.", + "type": "string", + "const": "animus.chat.application_controls.v1" + } + ] + }, + "ApplicationConfiguredRef": { + "description": "A bounded reference to a server-configured agent profile or skill.", + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "ApplicationPermissionIntent": { + "description": "Provider-neutral permission intent selected by an application.", + "oneOf": [ + { + "description": "Inherit safe provider defaults.", + "type": "string", + "const": "default" + }, + { + "description": "Review without broad edit permission.", + "type": "string", + "const": "review" + }, + { + "description": "Permit ordinary automated edits.", + "type": "string", + "const": "auto_edit" + }, + { + "description": "Request the profile's explicitly authorized unrestricted mode.", + "type": "string", + "const": "unrestricted" + } + ] + }, + "ApplicationReasoningEffort": { + "description": "Application-safe reasoning effort requested from an authorized profile.", + "oneOf": [ + { + "description": "Low reasoning effort.", + "type": "string", + "const": "low" + }, + { + "description": "Medium reasoning effort.", + "type": "string", + "const": "medium" + }, + { + "description": "High reasoning effort.", + "type": "string", + "const": "high" + } + ] + } + } +} diff --git a/schemas/animus-application-protocol/ApplicationChatControls.json b/schemas/animus-application-protocol/ApplicationChatControls.json new file mode 100644 index 0000000..671bc45 --- /dev/null +++ b/schemas/animus-application-protocol/ApplicationChatControls.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApplicationChatControls", + "description": "Closed application controls envelope accepted by `animus chat send`.", + "type": "object", + "properties": { + "approvals": { + "description": "Request kernel-mediated approvals when authorized by the profile.", + "type": "boolean" + }, + "permission_intent": { + "description": "Requested provider-neutral permission intent.", + "$ref": "#/$defs/ApplicationPermissionIntent" + }, + "profile_ref": { + "description": "Assertion of the conversation's canonical configured profile.", + "$ref": "#/$defs/ApplicationConfiguredRef" + }, + "reasoning_effort": { + "description": "Requested reasoning effort.", + "$ref": "#/$defs/ApplicationReasoningEffort" + }, + "schema": { + "description": "Exact controls schema discriminator.", + "$ref": "#/$defs/ApplicationChatControlsSchema" + }, + "skill_ref": { + "description": "Configured skill selected from the profile's authorized options.", + "$ref": "#/$defs/ApplicationConfiguredRef" + } + }, + "additionalProperties": false, + "required": [ + "schema" + ], + "$defs": { + "ApplicationChatControlsSchema": { + "description": "Stable schema discriminator for [`ApplicationChatControls`].", + "oneOf": [ + { + "description": "`animus.chat.application_controls.v1`.", + "type": "string", + "const": "animus.chat.application_controls.v1" + } + ] + }, + "ApplicationConfiguredRef": { + "description": "A bounded reference to a server-configured agent profile or skill.", + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "ApplicationPermissionIntent": { + "description": "Provider-neutral permission intent selected by an application.", + "oneOf": [ + { + "description": "Inherit safe provider defaults.", + "type": "string", + "const": "default" + }, + { + "description": "Review without broad edit permission.", + "type": "string", + "const": "review" + }, + { + "description": "Permit ordinary automated edits.", + "type": "string", + "const": "auto_edit" + }, + { + "description": "Request the profile's explicitly authorized unrestricted mode.", + "type": "string", + "const": "unrestricted" + } + ] + }, + "ApplicationReasoningEffort": { + "description": "Application-safe reasoning effort requested from an authorized profile.", + "oneOf": [ + { + "description": "Low reasoning effort.", + "type": "string", + "const": "low" + }, + { + "description": "Medium reasoning effort.", + "type": "string", + "const": "medium" + }, + { + "description": "High reasoning effort.", + "type": "string", + "const": "high" + } + ] + } + } +} diff --git a/schemas/animus-application-protocol/ApplicationChatReceiptFrame.json b/schemas/animus-application-protocol/ApplicationChatReceiptFrame.json new file mode 100644 index 0000000..e37e79f --- /dev/null +++ b/schemas/animus-application-protocol/ApplicationChatReceiptFrame.json @@ -0,0 +1,212 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApplicationChatReceiptFrame", + "description": "Exact durable JSONL receipt frames used by application chat callers.", + "oneOf": [ + { + "description": "Canonical user row was durably accepted.", + "type": "object", + "properties": { + "conversation_id": { + "description": "Conversation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "message_id": { + "description": "Canonical user-message identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "operation_id": { + "description": "Durable operation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "seq": { + "description": "Canonical user-message sequence.", + "$ref": "#/$defs/ApplicationChatSequence" + }, + "status": { + "description": "Must be `user_accepted`.", + "$ref": "#/$defs/UserAcceptedStatus" + }, + "type": { + "type": "string", + "const": "user_message_accepted" + } + }, + "additionalProperties": false, + "required": [ + "type", + "status", + "conversation_id", + "seq", + "message_id", + "operation_id" + ] + }, + { + "description": "Assistant row was durably completed.", + "type": "object", + "properties": { + "conversation_id": { + "description": "Conversation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "message_id": { + "description": "Canonical assistant-message identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "operation_id": { + "description": "Durable operation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "seq": { + "description": "Canonical assistant-message sequence.", + "$ref": "#/$defs/ApplicationChatSequence" + }, + "session_id": { + "description": "Optional provider continuity pointer.", + "anyOf": [ + { + "$ref": "#/$defs/ApplicationProtocolString" + }, + { + "type": "null" + } + ] + }, + "status": { + "description": "Must be `completed`.", + "$ref": "#/$defs/CompletedStatus" + }, + "type": { + "type": "string", + "const": "turn_completed" + }, + "user_message_id": { + "description": "Accepted user-message identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "user_seq": { + "description": "Accepted user-message sequence.", + "$ref": "#/$defs/ApplicationChatSequence" + } + }, + "additionalProperties": false, + "required": [ + "type", + "status", + "conversation_id", + "seq", + "message_id", + "user_seq", + "user_message_id", + "operation_id" + ] + }, + { + "description": "User row is durable but assistant execution did not complete.", + "type": "object", + "properties": { + "conversation_id": { + "description": "Conversation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "error_code": { + "description": "Stable, non-sensitive failure code.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "error_message": { + "description": "Safe bounded failure description.", + "$ref": "#/$defs/ApplicationChatErrorMessage" + }, + "operation_id": { + "description": "Durable operation identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "status": { + "description": "Confirmed partial-success status.", + "$ref": "#/$defs/ApplicationChatFailureStatus" + }, + "type": { + "type": "string", + "const": "turn_failed" + }, + "user_message_id": { + "description": "Accepted user-message identity.", + "$ref": "#/$defs/ApplicationProtocolString" + }, + "user_seq": { + "description": "Accepted user-message sequence.", + "$ref": "#/$defs/ApplicationChatSequence" + } + }, + "additionalProperties": false, + "required": [ + "type", + "status", + "conversation_id", + "user_seq", + "user_message_id", + "operation_id", + "error_code", + "error_message" + ] + } + ], + "$defs": { + "ApplicationChatErrorMessage": { + "description": "Safe bounded diagnostic for a confirmed assistant failure receipt.", + "type": "string", + "maxLength": 1024, + "minLength": 1 + }, + "ApplicationChatFailureStatus": { + "description": "Confirmed partial-success terminal status.", + "oneOf": [ + { + "description": "Provider or assistant execution failed.", + "type": "string", + "const": "assistant_failed" + }, + { + "description": "Provider or assistant execution was interrupted.", + "type": "string", + "const": "assistant_interrupted" + } + ] + }, + "ApplicationChatSequence": { + "description": "A non-negative chat sequence exactly representable by JavaScript clients.", + "type": "integer", + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0 + }, + "ApplicationProtocolString": { + "description": "A bounded, non-empty receipt identifier with no ASCII control characters.", + "type": "string", + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$" + }, + "CompletedStatus": { + "description": "Literal completed-turn status.", + "oneOf": [ + { + "description": "`completed`.", + "type": "string", + "const": "completed" + } + ] + }, + "UserAcceptedStatus": { + "description": "Literal accepted-user status.", + "oneOf": [ + { + "description": "`user_accepted`.", + "type": "string", + "const": "user_accepted" + } + ] + } + } +} diff --git a/schemas/animus-application-protocol/ApplicationChatTurnStatus.json b/schemas/animus-application-protocol/ApplicationChatTurnStatus.json new file mode 100644 index 0000000..4df721a --- /dev/null +++ b/schemas/animus-application-protocol/ApplicationChatTurnStatus.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApplicationChatTurnStatus", + "description": "Terminal status carried by an application chat receipt.", + "oneOf": [ + { + "description": "Assistant message persisted successfully.", + "type": "string", + "const": "completed" + }, + { + "description": "User message persisted but the provider failed.", + "type": "string", + "const": "assistant_failed" + }, + { + "description": "User message persisted but the provider was interrupted.", + "type": "string", + "const": "assistant_interrupted" + } + ] +} diff --git a/schemas/animus-application-protocol/ApplicationResourceKind.json b/schemas/animus-application-protocol/ApplicationResourceKind.json new file mode 100644 index 0000000..e9599ab --- /dev/null +++ b/schemas/animus-application-protocol/ApplicationResourceKind.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApplicationResourceKind", + "description": "Resource kinds exposed by the application projection boundary.", + "oneOf": [ + { + "description": "Configured agent profile.", + "type": "string", + "const": "agent" + }, + { + "description": "Durable chat conversation.", + "type": "string", + "const": "chat" + }, + { + "description": "Workflow definition.", + "type": "string", + "const": "workflow" + }, + { + "description": "Workflow execution.", + "type": "string", + "const": "run" + }, + { + "description": "Backend-owned subject.", + "type": "string", + "const": "subject" + }, + { + "description": "Queue entry.", + "type": "string", + "const": "queue_entry" + }, + { + "description": "Durable operation record.", + "type": "string", + "const": "operation" + }, + { + "description": "Human interaction request.", + "type": "string", + "const": "interaction" + } + ] +} diff --git a/schemas/animus-application-protocol/ResourceVisibility.json b/schemas/animus-application-protocol/ResourceVisibility.json new file mode 100644 index 0000000..f71a7cb --- /dev/null +++ b/schemas/animus-application-protocol/ResourceVisibility.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ResourceVisibility", + "description": "Visibility vocabulary carried by application resource projections.", + "oneOf": [ + { + "description": "Visible only through an explicit relationship or administration.", + "type": "string", + "const": "private" + }, + { + "description": "Visible within the authenticated organization boundary.", + "type": "string", + "const": "org" + }, + { + "description": "Publicly readable.", + "type": "string", + "const": "public" + } + ] +} diff --git a/schemas/animus-application-protocol/_all.json b/schemas/animus-application-protocol/_all.json new file mode 100644 index 0000000..f1441c1 --- /dev/null +++ b/schemas/animus-application-protocol/_all.json @@ -0,0 +1,500 @@ +{ + "$defs": { + "AllowedAction": { + "description": "Portal-derived action a client may render for a projected resource.\n\nThis enum standardizes the wire vocabulary; it does not grant an action.\nThe authenticated application boundary remains the authority that derives\nthe array for each caller and resource.", + "oneOf": [ + { + "const": "read", + "description": "Read the resource projection.", + "type": "string" + }, + { + "const": "inspect", + "description": "Inspect an agent's safe configuration projection.", + "type": "string" + }, + { + "const": "launch", + "description": "Launch a workflow run from the resource.", + "type": "string" + }, + { + "const": "stream", + "description": "Mint or consume a stream grant for a run.", + "type": "string" + }, + { + "const": "send", + "description": "Send a message to a conversation.", + "type": "string" + }, + { + "const": "respond", + "description": "Respond to an interaction.", + "type": "string" + } + ], + "title": "AllowedAction" + }, + "AllowedApplicationChatControls": { + "additionalProperties": false, + "description": "Allowed application control values projected by an authenticated portal.\n\nThis is a transport shape only. The portal decides which values appear.", + "properties": { + "approvals": { + "default": [], + "description": "Allowed approval selections.", + "items": { + "type": "boolean" + }, + "type": "array" + }, + "permission_intent": { + "default": [], + "description": "Allowed permission intent selections.", + "items": { + "$ref": "#/$defs/ApplicationPermissionIntent" + }, + "type": "array" + }, + "profile_ref": { + "default": [], + "description": "Allowed canonical profile assertion.", + "items": { + "$ref": "#/$defs/ApplicationConfiguredRef" + }, + "type": "array" + }, + "reasoning_effort": { + "default": [], + "description": "Allowed reasoning effort selections.", + "items": { + "$ref": "#/$defs/ApplicationReasoningEffort" + }, + "type": "array" + }, + "schema": { + "$ref": "#/$defs/ApplicationChatControlsSchema", + "description": "Exact controls schema discriminator." + }, + "skill_ref": { + "default": [], + "description": "Allowed configured skill references.", + "items": { + "$ref": "#/$defs/ApplicationConfiguredRef" + }, + "type": "array" + } + }, + "required": [ + "schema" + ], + "title": "AllowedApplicationChatControls", + "type": "object" + }, + "ApplicationChatControls": { + "additionalProperties": false, + "description": "Closed application controls envelope accepted by `animus chat send`.", + "properties": { + "approvals": { + "description": "Request kernel-mediated approvals when authorized by the profile.", + "type": "boolean" + }, + "permission_intent": { + "$ref": "#/$defs/ApplicationPermissionIntent", + "description": "Requested provider-neutral permission intent." + }, + "profile_ref": { + "$ref": "#/$defs/ApplicationConfiguredRef", + "description": "Assertion of the conversation's canonical configured profile." + }, + "reasoning_effort": { + "$ref": "#/$defs/ApplicationReasoningEffort", + "description": "Requested reasoning effort." + }, + "schema": { + "$ref": "#/$defs/ApplicationChatControlsSchema", + "description": "Exact controls schema discriminator." + }, + "skill_ref": { + "$ref": "#/$defs/ApplicationConfiguredRef", + "description": "Configured skill selected from the profile's authorized options." + } + }, + "required": [ + "schema" + ], + "title": "ApplicationChatControls", + "type": "object" + }, + "ApplicationChatControlsSchema": { + "description": "Stable schema discriminator for [`ApplicationChatControls`].", + "oneOf": [ + { + "const": "animus.chat.application_controls.v1", + "description": "`animus.chat.application_controls.v1`.", + "type": "string" + } + ] + }, + "ApplicationChatErrorMessage": { + "description": "Safe bounded diagnostic for a confirmed assistant failure receipt.", + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "ApplicationChatFailureStatus": { + "description": "Confirmed partial-success terminal status.", + "oneOf": [ + { + "const": "assistant_failed", + "description": "Provider or assistant execution failed.", + "type": "string" + }, + { + "const": "assistant_interrupted", + "description": "Provider or assistant execution was interrupted.", + "type": "string" + } + ] + }, + "ApplicationChatReceiptFrame": { + "description": "Exact durable JSONL receipt frames used by application chat callers.", + "oneOf": [ + { + "additionalProperties": false, + "description": "Canonical user row was durably accepted.", + "properties": { + "conversation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Conversation identity." + }, + "message_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Canonical user-message identity." + }, + "operation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Durable operation identity." + }, + "seq": { + "$ref": "#/$defs/ApplicationChatSequence", + "description": "Canonical user-message sequence." + }, + "status": { + "$ref": "#/$defs/UserAcceptedStatus", + "description": "Must be `user_accepted`." + }, + "type": { + "const": "user_message_accepted", + "type": "string" + } + }, + "required": [ + "type", + "status", + "conversation_id", + "seq", + "message_id", + "operation_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Assistant row was durably completed.", + "properties": { + "conversation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Conversation identity." + }, + "message_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Canonical assistant-message identity." + }, + "operation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Durable operation identity." + }, + "seq": { + "$ref": "#/$defs/ApplicationChatSequence", + "description": "Canonical assistant-message sequence." + }, + "session_id": { + "anyOf": [ + { + "$ref": "#/$defs/ApplicationProtocolString" + }, + { + "type": "null" + } + ], + "description": "Optional provider continuity pointer." + }, + "status": { + "$ref": "#/$defs/CompletedStatus", + "description": "Must be `completed`." + }, + "type": { + "const": "turn_completed", + "type": "string" + }, + "user_message_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Accepted user-message identity." + }, + "user_seq": { + "$ref": "#/$defs/ApplicationChatSequence", + "description": "Accepted user-message sequence." + } + }, + "required": [ + "type", + "status", + "conversation_id", + "seq", + "message_id", + "user_seq", + "user_message_id", + "operation_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "User row is durable but assistant execution did not complete.", + "properties": { + "conversation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Conversation identity." + }, + "error_code": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Stable, non-sensitive failure code." + }, + "error_message": { + "$ref": "#/$defs/ApplicationChatErrorMessage", + "description": "Safe bounded failure description." + }, + "operation_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Durable operation identity." + }, + "status": { + "$ref": "#/$defs/ApplicationChatFailureStatus", + "description": "Confirmed partial-success status." + }, + "type": { + "const": "turn_failed", + "type": "string" + }, + "user_message_id": { + "$ref": "#/$defs/ApplicationProtocolString", + "description": "Accepted user-message identity." + }, + "user_seq": { + "$ref": "#/$defs/ApplicationChatSequence", + "description": "Accepted user-message sequence." + } + }, + "required": [ + "type", + "status", + "conversation_id", + "user_seq", + "user_message_id", + "operation_id", + "error_code", + "error_message" + ], + "type": "object" + } + ], + "title": "ApplicationChatReceiptFrame" + }, + "ApplicationChatSequence": { + "description": "A non-negative chat sequence exactly representable by JavaScript clients.", + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "ApplicationChatTurnStatus": { + "description": "Terminal status carried by an application chat receipt.", + "oneOf": [ + { + "const": "completed", + "description": "Assistant message persisted successfully.", + "type": "string" + }, + { + "const": "assistant_failed", + "description": "User message persisted but the provider failed.", + "type": "string" + }, + { + "const": "assistant_interrupted", + "description": "User message persisted but the provider was interrupted.", + "type": "string" + } + ], + "title": "ApplicationChatTurnStatus" + }, + "ApplicationConfiguredRef": { + "description": "A bounded reference to a server-configured agent profile or skill.", + "maxLength": 64, + "minLength": 1, + "pattern": "^(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9._-]*$", + "type": "string" + }, + "ApplicationPermissionIntent": { + "description": "Provider-neutral permission intent selected by an application.", + "oneOf": [ + { + "const": "default", + "description": "Inherit safe provider defaults.", + "type": "string" + }, + { + "const": "review", + "description": "Review without broad edit permission.", + "type": "string" + }, + { + "const": "auto_edit", + "description": "Permit ordinary automated edits.", + "type": "string" + }, + { + "const": "unrestricted", + "description": "Request the profile's explicitly authorized unrestricted mode.", + "type": "string" + } + ] + }, + "ApplicationProtocolString": { + "description": "A bounded, non-empty receipt identifier with no ASCII control characters.", + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$", + "type": "string" + }, + "ApplicationReasoningEffort": { + "description": "Application-safe reasoning effort requested from an authorized profile.", + "oneOf": [ + { + "const": "low", + "description": "Low reasoning effort.", + "type": "string" + }, + { + "const": "medium", + "description": "Medium reasoning effort.", + "type": "string" + }, + { + "const": "high", + "description": "High reasoning effort.", + "type": "string" + } + ] + }, + "ApplicationResourceKind": { + "description": "Resource kinds exposed by the application projection boundary.", + "oneOf": [ + { + "const": "agent", + "description": "Configured agent profile.", + "type": "string" + }, + { + "const": "chat", + "description": "Durable chat conversation.", + "type": "string" + }, + { + "const": "workflow", + "description": "Workflow definition.", + "type": "string" + }, + { + "const": "run", + "description": "Workflow execution.", + "type": "string" + }, + { + "const": "subject", + "description": "Backend-owned subject.", + "type": "string" + }, + { + "const": "queue_entry", + "description": "Queue entry.", + "type": "string" + }, + { + "const": "operation", + "description": "Durable operation record.", + "type": "string" + }, + { + "const": "interaction", + "description": "Human interaction request.", + "type": "string" + } + ], + "title": "ApplicationResourceKind" + }, + "CompletedStatus": { + "description": "Literal completed-turn status.", + "oneOf": [ + { + "const": "completed", + "description": "`completed`.", + "type": "string" + } + ] + }, + "ResourceVisibility": { + "description": "Visibility vocabulary carried by application resource projections.", + "oneOf": [ + { + "const": "private", + "description": "Visible only through an explicit relationship or administration.", + "type": "string" + }, + { + "const": "org", + "description": "Visible within the authenticated organization boundary.", + "type": "string" + }, + { + "const": "public", + "description": "Publicly readable.", + "type": "string" + } + ], + "title": "ResourceVisibility" + }, + "UserAcceptedStatus": { + "description": "Literal accepted-user status.", + "oneOf": [ + { + "const": "user_accepted", + "description": "`user_accepted`.", + "type": "string" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "animus-application-protocol", + "x-animus-limits": { + "application_chat_control_ref_max_utf8_bytes": 64, + "application_chat_controls_max_utf8_bytes": 2048, + "application_chat_controls_schema": "animus.chat.application_controls.v1", + "application_chat_error_max_utf8_bytes": 1024, + "application_chat_sequence_max": 9007199254740991, + "application_protocol_string_max_utf8_bytes": 512, + "schema": "animus.application.limits.v1" + } +} diff --git a/schemas/animus-application-protocol/_limits.json b/schemas/animus-application-protocol/_limits.json new file mode 100644 index 0000000..0b949c3 --- /dev/null +++ b/schemas/animus-application-protocol/_limits.json @@ -0,0 +1,9 @@ +{ + "application_chat_control_ref_max_utf8_bytes": 64, + "application_chat_controls_max_utf8_bytes": 2048, + "application_chat_controls_schema": "animus.chat.application_controls.v1", + "application_chat_error_max_utf8_bytes": 1024, + "application_chat_sequence_max": 9007199254740991, + "application_protocol_string_max_utf8_bytes": 512, + "schema": "animus.application.limits.v1" +} diff --git a/schemas/animus-config-protocol/ConfigLoadRequest.json b/schemas/animus-config-protocol/ConfigLoadRequest.json index faacac6..1a9d83d 100644 --- a/schemas/animus-config-protocol/ConfigLoadRequest.json +++ b/schemas/animus-config-protocol/ConfigLoadRequest.json @@ -4,6 +4,17 @@ "description": "Parameters for [`METHOD_CONFIG_LOAD`].\n\nCarries the project context the in-tree YAML scan reads from today: the\nproject root (where `.animus/` lives) and the `repo-scope` the kernel uses\nto locate scoped runtime state and keychain entries. A Postgres/API source\nuses `repo_scope` to select the right rows; the YAML source uses\n`project_root` to find the overlay files.", "type": "object", "properties": { + "actor": { + "description": "Transport-asserted caller identity, relayed verbatim by the kernel. A\nconfig source MAY use it to scope which config it returns (per-user or\nper-tenant overlays). `None` for daemon/system loads with no actor.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "project_root": { "description": "Absolute path to the project root (the directory whose `.animus/`\ncarries the config). The YAML source scans `.animus/workflows.yaml` +\n`.animus/workflows/*.yaml` under this root.", "type": "string" @@ -18,5 +29,34 @@ }, "required": [ "project_root" - ] + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + } + } } diff --git a/schemas/animus-config-protocol/ConfigValidateRequest.json b/schemas/animus-config-protocol/ConfigValidateRequest.json index db4b457..3c37e06 100644 --- a/schemas/animus-config-protocol/ConfigValidateRequest.json +++ b/schemas/animus-config-protocol/ConfigValidateRequest.json @@ -4,6 +4,17 @@ "description": "Parameters for [`METHOD_CONFIG_VALIDATE`].\n\nIdentical context to [`ConfigLoadRequest`]: a plugin-side syntactic\npre-check runs against the same project scope it would `config/load` from.", "type": "object", "properties": { + "actor": { + "description": "Transport-asserted caller identity (see [`ConfigLoadRequest::actor`]).", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "project_root": { "description": "Absolute path to the project root (see [`ConfigLoadRequest::project_root`]).", "type": "string" @@ -18,5 +29,34 @@ }, "required": [ "project_root" - ] + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + } + } } diff --git a/schemas/animus-config-protocol/_all.json b/schemas/animus-config-protocol/_all.json index 8841257..7ffce46 100644 --- a/schemas/animus-config-protocol/_all.json +++ b/schemas/animus-config-protocol/_all.json @@ -1,5 +1,32 @@ { "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, "CacheToken": { "description": "Opaque cache key a config source returns so the kernel cache stays correct\nfor non-file sources.\n\nThe in-tree YAML cache keys on file content + mtime. For Postgres / API\nsources the plugin instead returns a [`CacheToken`]: an opaque version\nstring (a Postgres `max(updated_at)`, an ETag, a content hash, ...) plus an\n[`Self::external_inputs`] flag that reproduces today's\n`sources_have_external_inputs` cache-bypass hazard (inputs like `${VAR}` /\n`${secret.X}` / `system_prompt_file:` that change without mutating the\nsource bytes). When [`Self::external_inputs`] is true the kernel bypasses\nits disk cache for that load, matching current YAML behavior.\n\nRFC open question #5 (cache-token contract) is the reason this is a small\nstructured shape rather than a bare opaque string: it must cover the\n\"external inputs changed but source bytes didn't\" case explicitly.", "properties": { @@ -80,6 +107,17 @@ "ConfigLoadRequest": { "description": "Parameters for [`METHOD_CONFIG_LOAD`].\n\nCarries the project context the in-tree YAML scan reads from today: the\nproject root (where `.animus/` lives) and the `repo-scope` the kernel uses\nto locate scoped runtime state and keychain entries. A Postgres/API source\nuses `repo_scope` to select the right rows; the YAML source uses\n`project_root` to find the overlay files.", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Transport-asserted caller identity, relayed verbatim by the kernel. A\nconfig source MAY use it to scope which config it returns (per-user or\nper-tenant overlays). `None` for daemon/system loads with no actor." + }, "project_root": { "description": "Absolute path to the project root (the directory whose `.animus/`\ncarries the config). The YAML source scans `.animus/workflows.yaml` +\n`.animus/workflows/*.yaml` under this root.", "type": "string" @@ -145,6 +183,17 @@ "ConfigValidateRequest": { "description": "Parameters for [`METHOD_CONFIG_VALIDATE`].\n\nIdentical context to [`ConfigLoadRequest`]: a plugin-side syntactic\npre-check runs against the same project scope it would `config/load` from.", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Transport-asserted caller identity (see [`ConfigLoadRequest::actor`])." + }, "project_root": { "description": "Absolute path to the project root (see [`ConfigLoadRequest::project_root`]).", "type": "string" diff --git a/schemas/animus-environment-protocol/EnvironmentHandle.json b/schemas/animus-environment-protocol/EnvironmentHandle.json new file mode 100644 index 0000000..9bb144d --- /dev/null +++ b/schemas/animus-environment-protocol/EnvironmentHandle.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentHandle", + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "type": "object", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ] +} diff --git a/schemas/animus-environment-protocol/EnvironmentNode.json b/schemas/animus-environment-protocol/EnvironmentNode.json new file mode 100644 index 0000000..cbc9830 --- /dev/null +++ b/schemas/animus-environment-protocol/EnvironmentNode.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentNode", + "description": "A managed environment instance (a \"node\") as reported by the node-management\nsurface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod.", + "type": "object", + "properties": { + "created_at": { + "description": "Creation timestamp (ISO 8601) when the substrate exposes it.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Substrate-native id (railway service id, container id, ...).", + "type": "string" + }, + "image": { + "description": "Image / impl ref backing the node, when known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-facing name (e.g. `animus-run-`).", + "type": "string" + }, + "orphan": { + "description": "True when the node has no live owning run (a reap candidate).", + "type": "boolean" + }, + "run_id": { + "description": "The animus run id this node serves, when recoverable.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`,\n`CRASHED`, `unknown`, ...).", + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "orphan" + ] +} diff --git a/schemas/animus-environment-protocol/EnvironmentSpec.json b/schemas/animus-environment-protocol/EnvironmentSpec.json new file mode 100644 index 0000000..53ea125 --- /dev/null +++ b/schemas/animus-environment-protocol/EnvironmentSpec.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EnvironmentSpec", + "description": "Declarative description of the execution context to materialize.\n\n[`Self::kind`] names the environment plugin id (e.g. `\"worktree\"`,\n`\"container\"`, `\"railway\"`) so the kernel can route a `prepare` call to the\nright plugin. The rest of the spec is intentionally open: [`Self::image`],\n[`Self::resources`], and [`Self::metadata`] carry plugin-specific knobs that\nthe kernel passes through opaquely.", + "type": "object", + "properties": { + "env": { + "description": "Environment variables to inject into every command run in this context.\nNon-secret config only — secrets flow through the kernel's secret store,\nnot this field.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "image": { + "description": "Container/VM image reference for image-based environments (Docker tag,\nOCI ref, AMI id, ...). Ignored by the worktree environment.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "description": "Environment plugin id that should service this spec (the plugin's\ndeclared environment kind, not [`animus_plugin_protocol::PluginKind`]).", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata (labels, base_ref, network mode,\nmounts, ...). Carried opaquely by the kernel." + }, + "repos": { + "description": "The repo set / workspace to materialize. May be empty for a\nrepo-less environment (e.g. a scratch container).", + "type": "array", + "items": { + "$ref": "#/$defs/RepoRef" + } + }, + "resources": { + "description": "Resource requests/limits for the environment (cpu, memory, disk,\ntimeout, region, ...). Shape is plugin-defined; carried opaquely." + } + }, + "required": [ + "kind" + ], + "$defs": { + "RepoRef": { + "description": "A single repository in an environment's workspace (repo set).\n\nA multi-repo workspace (see the top-level `workspace:` config) checks out\nmore than one `RepoRef` under [`EnvironmentHandle::workspace_root`], each in\nits own subdirectory named by [`RepoRef::name`] (or derived from\n[`RepoRef::url`] when `name` is unset).", + "type": "object", + "properties": { + "git_ref": { + "description": "Git ref (branch, tag, or commit) to check out. When unset, the plugin\nuses the remote's default branch.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Subdirectory name to check the repo out under, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, the plugin derives it\nfrom the last path segment of [`Self::url`].", + "type": [ + "string", + "null" + ] + }, + "primary": { + "description": "True when this repo is the primary workspace repo (the one a\nsingle-repo subject maps to, and the default `cwd` for\n[`HarnessCommand`]). At most one repo in a set should be primary; when\nnone is marked, the first entry is primary.", + "type": "boolean" + }, + "url": { + "description": "Clone URL or local path for the repository. The originating environment\nplugin interprets the value (an `https://`/`git@` remote for the\nworktree/container/remote runners, or a local path for a bind-mount).", + "type": "string" + } + }, + "required": [ + "url" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/ExecNotification.json b/schemas/animus-environment-protocol/ExecNotification.json new file mode 100644 index 0000000..e21a630 --- /dev/null +++ b/schemas/animus-environment-protocol/ExecNotification.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecNotification", + "description": "A streaming notification an environment plugin emits mid-exec during a\n[`METHOD_ENVIRONMENT_EXEC_STREAM`] call.\n\nThe runtime wraps these into [`NOTIFICATION_ENVIRONMENT_OUTPUT`] JSON-RPC\nnotifications and forwards them to the host on the same channel as the\neventual [`ExecResponse`] reply. This mirrors\n`animus-provider-protocol`'s `AgentNotification` server-streaming surface.", + "oneOf": [ + { + "description": "Incremental stdout/stderr the command has produced. Maps to\n[`NOTIFICATION_ENVIRONMENT_OUTPUT`].", + "type": "object", + "properties": { + "handle_id": { + "description": "Handle id of the environment this exec runs in.", + "type": "string" + }, + "kind": { + "type": "string", + "const": "output" + }, + "stream": { + "description": "Which stream this delta belongs to.", + "$ref": "#/$defs/ExecStream" + }, + "text": { + "description": "The output delta (UTF-8).", + "type": "string" + } + }, + "required": [ + "kind", + "handle_id", + "stream", + "text" + ] + }, + { + "description": "One journal event from an in-flight [`METHOD_ENVIRONMENT_EXEC_SESSION`],\nforwarded verbatim from the node's own workflow journal. Maps to\n[`NOTIFICATION_ENVIRONMENT_JOURNAL`].", + "type": "object", + "properties": { + "event_kind": { + "description": "The journal event kind (e.g. `phase_started`, `output_chunk`,\n`tool_call`, `run_failed`).", + "type": "string" + }, + "handle_id": { + "description": "Handle id of the environment this session runs in.", + "type": "string" + }, + "kind": { + "type": "string", + "const": "journal" + }, + "payload": { + "description": "The event's full payload, forwarded verbatim." + }, + "phase_id": { + "description": "Phase this event belongs to, when phase-scoped.", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "Event status discriminator, when present.", + "type": [ + "string", + "null" + ] + }, + "terminal": { + "description": "True on the final event of the session (the run reached a terminal\nstatus), signalling the host to settle.", + "type": "boolean" + }, + "ts": { + "description": "Event timestamp (RFC 3339).", + "type": "string" + }, + "workflow_id": { + "description": "The node-local run id this event belongs to.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "handle_id", + "event_kind", + "ts", + "payload" + ] + } + ], + "$defs": { + "ExecStream": { + "description": "Which output stream an [`ExecNotification`] delta belongs to.", + "oneOf": [ + { + "description": "Standard output.", + "type": "string", + "const": "stdout" + }, + { + "description": "Standard error.", + "type": "string", + "const": "stderr" + } + ] + } + } +} diff --git a/schemas/animus-environment-protocol/ExecRequest.json b/schemas/animus-environment-protocol/ExecRequest.json new file mode 100644 index 0000000..9a42645 --- /dev/null +++ b/schemas/animus-environment-protocol/ExecRequest.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_EXEC`] and\n[`METHOD_ENVIRONMENT_EXEC_STREAM`].", + "type": "object", + "properties": { + "command": { + "description": "The command to run.", + "$ref": "#/$defs/HarnessCommand" + }, + "handle": { + "description": "The prepared context to run in.", + "$ref": "#/$defs/EnvironmentHandle" + }, + "stdin": { + "description": "Bytes to feed to the command's stdin, up front, as a UTF-8 string.", + "type": [ + "string", + "null" + ] + }, + "timeout_secs": { + "description": "Hard wall-clock timeout in seconds. When exceeded the environment kills\nthe command and returns [`ExecResponse::timed_out`] = true. `None` means\nno explicit timeout (the environment may still impose its own).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "handle", + "command" + ], + "$defs": { + "EnvironmentHandle": { + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "type": "object", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ] + }, + "HarnessCommand": { + "description": "A command to run inside a prepared environment.\n\nThis is the harness invocation the provider layer would otherwise run\ndirectly on the host; the environment plugin runs it inside the prepared\ncontext instead.", + "type": "object", + "properties": { + "args": { + "description": "Arguments passed to [`Self::program`], not including `argv[0]`.", + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for the command, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, runs in the primary\nrepo's directory (or the workspace root when there is no primary repo).", + "type": [ + "string", + "null" + ] + }, + "env": { + "description": "Extra environment variables for this command, merged over (and\noverriding) [`EnvironmentSpec::env`].", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "program": { + "description": "Executable to run (looked up on the environment's `PATH` unless\nabsolute).", + "type": "string" + } + }, + "required": [ + "program" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/ExecResponse.json b/schemas/animus-environment-protocol/ExecResponse.json new file mode 100644 index 0000000..296d980 --- /dev/null +++ b/schemas/animus-environment-protocol/ExecResponse.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_EXEC`] /\n[`METHOD_ENVIRONMENT_EXEC_STREAM`].\n\nFor [`METHOD_ENVIRONMENT_EXEC_STREAM`], [`Self::stdout`] / [`Self::stderr`]\ncarry the aggregated output already delivered incrementally via\n[`ExecNotification`]s; a client that consumed the stream can ignore them.", + "type": "object", + "properties": { + "exit_code": { + "description": "Process exit code. `None` when the process was terminated by a signal or\nkilled on timeout without producing an exit code.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "stderr": { + "description": "Aggregated stderr captured from the command.", + "type": "string" + }, + "stdout": { + "description": "Aggregated stdout captured from the command.", + "type": "string" + }, + "timed_out": { + "description": "True when the command was killed because it exceeded\n[`ExecRequest::timeout_secs`].", + "type": "boolean" + } + } +} diff --git a/schemas/animus-environment-protocol/ExecSessionRequest.json b/schemas/animus-environment-protocol/ExecSessionRequest.json new file mode 100644 index 0000000..fdc8d45 --- /dev/null +++ b/schemas/animus-environment-protocol/ExecSessionRequest.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecSessionRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`] — dispatch a subject\nto the environment's own animus (REQ-052 remote-animus).", + "type": "object", + "properties": { + "dispatch_input": { + "description": "Optional dispatch input forwarded to the node's run.", + "type": [ + "string", + "null" + ] + }, + "handle": { + "description": "The prepared context (whose in-container animus runs the subject).", + "$ref": "#/$defs/EnvironmentHandle" + }, + "subject_id": { + "description": "The subject to dispatch, qualified `kind:id` (e.g. `task:TASK-1`).", + "type": "string" + }, + "workflow_id": { + "description": "The DELEGATING run's workflow id (REQ-052 one-id). When set, the node\nMUST execute INTO this already-bootstrapped run (resume-existing) rather\nthan minting its own, so exactly ONE journal row exists for the dispatch\nand the node's transcript lands on the id the portal reads. `None` keeps\nthe legacy behavior (the node mints its own run id).", + "type": [ + "string", + "null" + ] + }, + "workflow_ref": { + "description": "Workflow to run the subject through. `None` uses the node's default\nrouting for the subject's kind.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "handle", + "subject_id" + ], + "$defs": { + "EnvironmentHandle": { + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "type": "object", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/ExecSessionResponse.json b/schemas/animus-environment-protocol/ExecSessionResponse.json new file mode 100644 index 0000000..b5da3ec --- /dev/null +++ b/schemas/animus-environment-protocol/ExecSessionResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecSessionResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`]: the node-local run\nid the dispatch spawned and its terminal status.", + "type": "object", + "properties": { + "status": { + "description": "Terminal status of the node-local run (e.g. `completed`, `failed`,\n`escalated`, `cancelled`, or `no-run` when nothing was dispatched).", + "type": "string" + }, + "workflow_id": { + "description": "The node-local workflow run id, when one was spawned.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ] +} diff --git a/schemas/animus-environment-protocol/ExecStream.json b/schemas/animus-environment-protocol/ExecStream.json new file mode 100644 index 0000000..9f73df7 --- /dev/null +++ b/schemas/animus-environment-protocol/ExecStream.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ExecStream", + "description": "Which output stream an [`ExecNotification`] delta belongs to.", + "oneOf": [ + { + "description": "Standard output.", + "type": "string", + "const": "stdout" + }, + { + "description": "Standard error.", + "type": "string", + "const": "stderr" + } + ] +} diff --git a/schemas/animus-environment-protocol/GetNodeRequest.json b/schemas/animus-environment-protocol/GetNodeRequest.json new file mode 100644 index 0000000..d085757 --- /dev/null +++ b/schemas/animus-environment-protocol/GetNodeRequest.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GetNodeRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_GET`].", + "type": "object", + "properties": { + "id": { + "description": "Substrate id or name of the node to describe.", + "type": "string" + } + }, + "required": [ + "id" + ] +} diff --git a/schemas/animus-environment-protocol/GetNodeResponse.json b/schemas/animus-environment-protocol/GetNodeResponse.json new file mode 100644 index 0000000..9bcf732 --- /dev/null +++ b/schemas/animus-environment-protocol/GetNodeResponse.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GetNodeResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_GET`].", + "type": "object", + "properties": { + "node": { + "description": "The node, or null when no node matched.", + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentNode" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "$defs": { + "EnvironmentNode": { + "description": "A managed environment instance (a \"node\") as reported by the node-management\nsurface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod.", + "type": "object", + "properties": { + "created_at": { + "description": "Creation timestamp (ISO 8601) when the substrate exposes it.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Substrate-native id (railway service id, container id, ...).", + "type": "string" + }, + "image": { + "description": "Image / impl ref backing the node, when known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-facing name (e.g. `animus-run-`).", + "type": "string" + }, + "orphan": { + "description": "True when the node has no live owning run (a reap candidate).", + "type": "boolean" + }, + "run_id": { + "description": "The animus run id this node serves, when recoverable.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`,\n`CRASHED`, `unknown`, ...).", + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "orphan" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/HarnessCommand.json b/schemas/animus-environment-protocol/HarnessCommand.json new file mode 100644 index 0000000..f43ea3b --- /dev/null +++ b/schemas/animus-environment-protocol/HarnessCommand.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "HarnessCommand", + "description": "A command to run inside a prepared environment.\n\nThis is the harness invocation the provider layer would otherwise run\ndirectly on the host; the environment plugin runs it inside the prepared\ncontext instead.", + "type": "object", + "properties": { + "args": { + "description": "Arguments passed to [`Self::program`], not including `argv[0]`.", + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for the command, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, runs in the primary\nrepo's directory (or the workspace root when there is no primary repo).", + "type": [ + "string", + "null" + ] + }, + "env": { + "description": "Extra environment variables for this command, merged over (and\noverriding) [`EnvironmentSpec::env`].", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "program": { + "description": "Executable to run (looked up on the environment's `PATH` unless\nabsolute).", + "type": "string" + } + }, + "required": [ + "program" + ] +} diff --git a/schemas/animus-environment-protocol/ListNodesRequest.json b/schemas/animus-environment-protocol/ListNodesRequest.json new file mode 100644 index 0000000..c473e9f --- /dev/null +++ b/schemas/animus-environment-protocol/ListNodesRequest.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ListNodesRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_LIST`] (no parameters).", + "type": "object" +} diff --git a/schemas/animus-environment-protocol/ListNodesResponse.json b/schemas/animus-environment-protocol/ListNodesResponse.json new file mode 100644 index 0000000..c7dffb2 --- /dev/null +++ b/schemas/animus-environment-protocol/ListNodesResponse.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ListNodesResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_LIST`].", + "type": "object", + "properties": { + "nodes": { + "description": "Every known environment node.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/EnvironmentNode" + } + } + }, + "$defs": { + "EnvironmentNode": { + "description": "A managed environment instance (a \"node\") as reported by the node-management\nsurface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod.", + "type": "object", + "properties": { + "created_at": { + "description": "Creation timestamp (ISO 8601) when the substrate exposes it.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Substrate-native id (railway service id, container id, ...).", + "type": "string" + }, + "image": { + "description": "Image / impl ref backing the node, when known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-facing name (e.g. `animus-run-`).", + "type": "string" + }, + "orphan": { + "description": "True when the node has no live owning run (a reap candidate).", + "type": "boolean" + }, + "run_id": { + "description": "The animus run id this node serves, when recoverable.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`,\n`CRASHED`, `unknown`, ...).", + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "orphan" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/PrepareRequest.json b/schemas/animus-environment-protocol/PrepareRequest.json new file mode 100644 index 0000000..ade6377 --- /dev/null +++ b/schemas/animus-environment-protocol/PrepareRequest.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PrepareRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_PREPARE`].", + "type": "object", + "properties": { + "spec": { + "description": "The environment to materialize.", + "$ref": "#/$defs/EnvironmentSpec" + } + }, + "required": [ + "spec" + ], + "$defs": { + "EnvironmentSpec": { + "description": "Declarative description of the execution context to materialize.\n\n[`Self::kind`] names the environment plugin id (e.g. `\"worktree\"`,\n`\"container\"`, `\"railway\"`) so the kernel can route a `prepare` call to the\nright plugin. The rest of the spec is intentionally open: [`Self::image`],\n[`Self::resources`], and [`Self::metadata`] carry plugin-specific knobs that\nthe kernel passes through opaquely.", + "type": "object", + "properties": { + "env": { + "description": "Environment variables to inject into every command run in this context.\nNon-secret config only — secrets flow through the kernel's secret store,\nnot this field.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "image": { + "description": "Container/VM image reference for image-based environments (Docker tag,\nOCI ref, AMI id, ...). Ignored by the worktree environment.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "description": "Environment plugin id that should service this spec (the plugin's\ndeclared environment kind, not [`animus_plugin_protocol::PluginKind`]).", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata (labels, base_ref, network mode,\nmounts, ...). Carried opaquely by the kernel." + }, + "repos": { + "description": "The repo set / workspace to materialize. May be empty for a\nrepo-less environment (e.g. a scratch container).", + "type": "array", + "items": { + "$ref": "#/$defs/RepoRef" + } + }, + "resources": { + "description": "Resource requests/limits for the environment (cpu, memory, disk,\ntimeout, region, ...). Shape is plugin-defined; carried opaquely." + } + }, + "required": [ + "kind" + ] + }, + "RepoRef": { + "description": "A single repository in an environment's workspace (repo set).\n\nA multi-repo workspace (see the top-level `workspace:` config) checks out\nmore than one `RepoRef` under [`EnvironmentHandle::workspace_root`], each in\nits own subdirectory named by [`RepoRef::name`] (or derived from\n[`RepoRef::url`] when `name` is unset).", + "type": "object", + "properties": { + "git_ref": { + "description": "Git ref (branch, tag, or commit) to check out. When unset, the plugin\nuses the remote's default branch.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Subdirectory name to check the repo out under, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, the plugin derives it\nfrom the last path segment of [`Self::url`].", + "type": [ + "string", + "null" + ] + }, + "primary": { + "description": "True when this repo is the primary workspace repo (the one a\nsingle-repo subject maps to, and the default `cwd` for\n[`HarnessCommand`]). At most one repo in a set should be primary; when\nnone is marked, the first entry is primary.", + "type": "boolean" + }, + "url": { + "description": "Clone URL or local path for the repository. The originating environment\nplugin interprets the value (an `https://`/`git@` remote for the\nworktree/container/remote runners, or a local path for a bind-mount).", + "type": "string" + } + }, + "required": [ + "url" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/PrepareResponse.json b/schemas/animus-environment-protocol/PrepareResponse.json new file mode 100644 index 0000000..afda919 --- /dev/null +++ b/schemas/animus-environment-protocol/PrepareResponse.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PrepareResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_PREPARE`].", + "type": "object", + "properties": { + "handle": { + "description": "Handle to the materialized context, used for subsequent `exec` and\n`teardown` calls.", + "$ref": "#/$defs/EnvironmentHandle" + } + }, + "required": [ + "handle" + ], + "$defs": { + "EnvironmentHandle": { + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "type": "object", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/ReapRequest.json b/schemas/animus-environment-protocol/ReapRequest.json new file mode 100644 index 0000000..99c15cd --- /dev/null +++ b/schemas/animus-environment-protocol/ReapRequest.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ReapRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_REAP`]. With no fields set, reap\ndeletes only dead nodes (always safe — a live node is never dead).", + "type": "object", + "properties": { + "all": { + "description": "Also reap non-dead nodes that have no live owning run (needs `force`).", + "type": "boolean" + }, + "dry_run": { + "description": "Report what WOULD be reaped without deleting anything.", + "type": "boolean" + }, + "force": { + "description": "Required alongside `all` — guards a fresh (no-liveness) reaper from\ntreating every healthy node as an orphan.", + "type": "boolean" + }, + "older_than_secs": { + "description": "Only reap nodes at least this many seconds old.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + } +} diff --git a/schemas/animus-environment-protocol/ReapResponse.json b/schemas/animus-environment-protocol/ReapResponse.json new file mode 100644 index 0000000..c0c25c8 --- /dev/null +++ b/schemas/animus-environment-protocol/ReapResponse.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ReapResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_REAP`].", + "type": "object", + "properties": { + "deleted": { + "description": "Substrate ids deleted (or that WOULD be deleted, when `dry_run`).", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "dry_run": { + "description": "Echoes the request's `dry_run`.", + "type": "boolean", + "default": false + }, + "kept": { + "description": "Nodes spared.", + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/EnvironmentNode" + } + } + }, + "$defs": { + "EnvironmentNode": { + "description": "A managed environment instance (a \"node\") as reported by the node-management\nsurface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod.", + "type": "object", + "properties": { + "created_at": { + "description": "Creation timestamp (ISO 8601) when the substrate exposes it.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Substrate-native id (railway service id, container id, ...).", + "type": "string" + }, + "image": { + "description": "Image / impl ref backing the node, when known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-facing name (e.g. `animus-run-`).", + "type": "string" + }, + "orphan": { + "description": "True when the node has no live owning run (a reap candidate).", + "type": "boolean" + }, + "run_id": { + "description": "The animus run id this node serves, when recoverable.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`,\n`CRASHED`, `unknown`, ...).", + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "orphan" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/RepoRef.json b/schemas/animus-environment-protocol/RepoRef.json new file mode 100644 index 0000000..dc4ff5c --- /dev/null +++ b/schemas/animus-environment-protocol/RepoRef.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "RepoRef", + "description": "A single repository in an environment's workspace (repo set).\n\nA multi-repo workspace (see the top-level `workspace:` config) checks out\nmore than one `RepoRef` under [`EnvironmentHandle::workspace_root`], each in\nits own subdirectory named by [`RepoRef::name`] (or derived from\n[`RepoRef::url`] when `name` is unset).", + "type": "object", + "properties": { + "git_ref": { + "description": "Git ref (branch, tag, or commit) to check out. When unset, the plugin\nuses the remote's default branch.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Subdirectory name to check the repo out under, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, the plugin derives it\nfrom the last path segment of [`Self::url`].", + "type": [ + "string", + "null" + ] + }, + "primary": { + "description": "True when this repo is the primary workspace repo (the one a\nsingle-repo subject maps to, and the default `cwd` for\n[`HarnessCommand`]). At most one repo in a set should be primary; when\nnone is marked, the first entry is primary.", + "type": "boolean" + }, + "url": { + "description": "Clone URL or local path for the repository. The originating environment\nplugin interprets the value (an `https://`/`git@` remote for the\nworktree/container/remote runners, or a local path for a bind-mount).", + "type": "string" + } + }, + "required": [ + "url" + ] +} diff --git a/schemas/animus-environment-protocol/TeardownNodeRequest.json b/schemas/animus-environment-protocol/TeardownNodeRequest.json new file mode 100644 index 0000000..745afd7 --- /dev/null +++ b/schemas/animus-environment-protocol/TeardownNodeRequest.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TeardownNodeRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`].", + "type": "object", + "properties": { + "id": { + "description": "Substrate id or name of the node to destroy.", + "type": "string" + } + }, + "required": [ + "id" + ] +} diff --git a/schemas/animus-environment-protocol/TeardownNodeResponse.json b/schemas/animus-environment-protocol/TeardownNodeResponse.json new file mode 100644 index 0000000..9b70620 --- /dev/null +++ b/schemas/animus-environment-protocol/TeardownNodeResponse.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TeardownNodeResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`]. Idempotent — an\nalready-gone node yields an empty `deleted`.", + "type": "object", + "properties": { + "deleted": { + "description": "Substrate ids actually deleted.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } +} diff --git a/schemas/animus-environment-protocol/TeardownRequest.json b/schemas/animus-environment-protocol/TeardownRequest.json new file mode 100644 index 0000000..cefaac7 --- /dev/null +++ b/schemas/animus-environment-protocol/TeardownRequest.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TeardownRequest", + "description": "Request payload for [`METHOD_ENVIRONMENT_TEARDOWN`].", + "type": "object", + "properties": { + "handle": { + "description": "The prepared context to dispose of.", + "$ref": "#/$defs/EnvironmentHandle" + } + }, + "required": [ + "handle" + ], + "$defs": { + "EnvironmentHandle": { + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "type": "object", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ] + } + } +} diff --git a/schemas/animus-environment-protocol/TeardownResponse.json b/schemas/animus-environment-protocol/TeardownResponse.json new file mode 100644 index 0000000..4b340f3 --- /dev/null +++ b/schemas/animus-environment-protocol/TeardownResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "TeardownResponse", + "description": "Response payload for [`METHOD_ENVIRONMENT_TEARDOWN`]. Empty on success; the\nwire-level error shape is `animus_plugin_protocol::RpcError`.", + "type": "object" +} diff --git a/schemas/animus-environment-protocol/_all.json b/schemas/animus-environment-protocol/_all.json new file mode 100644 index 0000000..026a8f3 --- /dev/null +++ b/schemas/animus-environment-protocol/_all.json @@ -0,0 +1,601 @@ +{ + "$defs": { + "EnvironmentHandle": { + "description": "Handle to a prepared execution context.\n\nThe kernel treats [`Self::id`] as opaque and passes the whole handle back on\nevery [`ExecRequest`] / [`TeardownRequest`]; only the originating plugin\ninterprets the id. [`Self::workspace_root`] is the absolute path (on the\nplugin's side of the world) that command `cwd`s resolve against.", + "properties": { + "id": { + "description": "Opaque, plugin-assigned identifier for this prepared context.", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata about the prepared context\n(container id, remote host, allocated ports, ...). Carried opaquely." + }, + "workspace_root": { + "description": "Absolute path to the root of the materialized workspace. Command `cwd`s\n([`HarnessCommand::cwd`]) resolve relative to this path; for a\nmulti-repo workspace each [`RepoRef`] lives in a subdirectory under it.", + "type": "string" + } + }, + "required": [ + "id", + "workspace_root" + ], + "title": "EnvironmentHandle", + "type": "object" + }, + "EnvironmentNode": { + "description": "A managed environment instance (a \"node\") as reported by the node-management\nsurface. Substrate-agnostic: a Railway service, a Docker container, a k8s pod.", + "properties": { + "created_at": { + "description": "Creation timestamp (ISO 8601) when the substrate exposes it.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Substrate-native id (railway service id, container id, ...).", + "type": "string" + }, + "image": { + "description": "Image / impl ref backing the node, when known.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Human-facing name (e.g. `animus-run-`).", + "type": "string" + }, + "orphan": { + "description": "True when the node has no live owning run (a reap candidate).", + "type": "boolean" + }, + "run_id": { + "description": "The animus run id this node serves, when recoverable.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "Lifecycle state as the substrate reports it (`SUCCESS`, `FAILED`,\n`CRASHED`, `unknown`, ...).", + "type": "string" + } + }, + "required": [ + "id", + "name", + "state", + "orphan" + ], + "title": "EnvironmentNode", + "type": "object" + }, + "EnvironmentSpec": { + "description": "Declarative description of the execution context to materialize.\n\n[`Self::kind`] names the environment plugin id (e.g. `\"worktree\"`,\n`\"container\"`, `\"railway\"`) so the kernel can route a `prepare` call to the\nright plugin. The rest of the spec is intentionally open: [`Self::image`],\n[`Self::resources`], and [`Self::metadata`] carry plugin-specific knobs that\nthe kernel passes through opaquely.", + "properties": { + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment variables to inject into every command run in this context.\nNon-secret config only — secrets flow through the kernel's secret store,\nnot this field.", + "type": "object" + }, + "image": { + "description": "Container/VM image reference for image-based environments (Docker tag,\nOCI ref, AMI id, ...). Ignored by the worktree environment.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "description": "Environment plugin id that should service this spec (the plugin's\ndeclared environment kind, not [`animus_plugin_protocol::PluginKind`]).", + "type": "string" + }, + "metadata": { + "description": "Free-form plugin-specific metadata (labels, base_ref, network mode,\nmounts, ...). Carried opaquely by the kernel." + }, + "repos": { + "description": "The repo set / workspace to materialize. May be empty for a\nrepo-less environment (e.g. a scratch container).", + "items": { + "$ref": "#/$defs/RepoRef" + }, + "type": "array" + }, + "resources": { + "description": "Resource requests/limits for the environment (cpu, memory, disk,\ntimeout, region, ...). Shape is plugin-defined; carried opaquely." + } + }, + "required": [ + "kind" + ], + "title": "EnvironmentSpec", + "type": "object" + }, + "ExecNotification": { + "description": "A streaming notification an environment plugin emits mid-exec during a\n[`METHOD_ENVIRONMENT_EXEC_STREAM`] call.\n\nThe runtime wraps these into [`NOTIFICATION_ENVIRONMENT_OUTPUT`] JSON-RPC\nnotifications and forwards them to the host on the same channel as the\neventual [`ExecResponse`] reply. This mirrors\n`animus-provider-protocol`'s `AgentNotification` server-streaming surface.", + "oneOf": [ + { + "description": "Incremental stdout/stderr the command has produced. Maps to\n[`NOTIFICATION_ENVIRONMENT_OUTPUT`].", + "properties": { + "handle_id": { + "description": "Handle id of the environment this exec runs in.", + "type": "string" + }, + "kind": { + "const": "output", + "type": "string" + }, + "stream": { + "$ref": "#/$defs/ExecStream", + "description": "Which stream this delta belongs to." + }, + "text": { + "description": "The output delta (UTF-8).", + "type": "string" + } + }, + "required": [ + "kind", + "handle_id", + "stream", + "text" + ], + "type": "object" + }, + { + "description": "One journal event from an in-flight [`METHOD_ENVIRONMENT_EXEC_SESSION`],\nforwarded verbatim from the node's own workflow journal. Maps to\n[`NOTIFICATION_ENVIRONMENT_JOURNAL`].", + "properties": { + "event_kind": { + "description": "The journal event kind (e.g. `phase_started`, `output_chunk`,\n`tool_call`, `run_failed`).", + "type": "string" + }, + "handle_id": { + "description": "Handle id of the environment this session runs in.", + "type": "string" + }, + "kind": { + "const": "journal", + "type": "string" + }, + "payload": { + "description": "The event's full payload, forwarded verbatim." + }, + "phase_id": { + "description": "Phase this event belongs to, when phase-scoped.", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "Event status discriminator, when present.", + "type": [ + "string", + "null" + ] + }, + "terminal": { + "description": "True on the final event of the session (the run reached a terminal\nstatus), signalling the host to settle.", + "type": "boolean" + }, + "ts": { + "description": "Event timestamp (RFC 3339).", + "type": "string" + }, + "workflow_id": { + "description": "The node-local run id this event belongs to.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "handle_id", + "event_kind", + "ts", + "payload" + ], + "type": "object" + } + ], + "title": "ExecNotification" + }, + "ExecRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_EXEC`] and\n[`METHOD_ENVIRONMENT_EXEC_STREAM`].", + "properties": { + "command": { + "$ref": "#/$defs/HarnessCommand", + "description": "The command to run." + }, + "handle": { + "$ref": "#/$defs/EnvironmentHandle", + "description": "The prepared context to run in." + }, + "stdin": { + "description": "Bytes to feed to the command's stdin, up front, as a UTF-8 string.", + "type": [ + "string", + "null" + ] + }, + "timeout_secs": { + "description": "Hard wall-clock timeout in seconds. When exceeded the environment kills\nthe command and returns [`ExecResponse::timed_out`] = true. `None` means\nno explicit timeout (the environment may still impose its own).", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "handle", + "command" + ], + "title": "ExecRequest", + "type": "object" + }, + "ExecResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_EXEC`] /\n[`METHOD_ENVIRONMENT_EXEC_STREAM`].\n\nFor [`METHOD_ENVIRONMENT_EXEC_STREAM`], [`Self::stdout`] / [`Self::stderr`]\ncarry the aggregated output already delivered incrementally via\n[`ExecNotification`]s; a client that consumed the stream can ignore them.", + "properties": { + "exit_code": { + "description": "Process exit code. `None` when the process was terminated by a signal or\nkilled on timeout without producing an exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "stderr": { + "description": "Aggregated stderr captured from the command.", + "type": "string" + }, + "stdout": { + "description": "Aggregated stdout captured from the command.", + "type": "string" + }, + "timed_out": { + "description": "True when the command was killed because it exceeded\n[`ExecRequest::timeout_secs`].", + "type": "boolean" + } + }, + "title": "ExecResponse", + "type": "object" + }, + "ExecSessionRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`] — dispatch a subject\nto the environment's own animus (REQ-052 remote-animus).", + "properties": { + "dispatch_input": { + "description": "Optional dispatch input forwarded to the node's run.", + "type": [ + "string", + "null" + ] + }, + "handle": { + "$ref": "#/$defs/EnvironmentHandle", + "description": "The prepared context (whose in-container animus runs the subject)." + }, + "subject_id": { + "description": "The subject to dispatch, qualified `kind:id` (e.g. `task:TASK-1`).", + "type": "string" + }, + "workflow_id": { + "description": "The DELEGATING run's workflow id (REQ-052 one-id). When set, the node\nMUST execute INTO this already-bootstrapped run (resume-existing) rather\nthan minting its own, so exactly ONE journal row exists for the dispatch\nand the node's transcript lands on the id the portal reads. `None` keeps\nthe legacy behavior (the node mints its own run id).", + "type": [ + "string", + "null" + ] + }, + "workflow_ref": { + "description": "Workflow to run the subject through. `None` uses the node's default\nrouting for the subject's kind.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "handle", + "subject_id" + ], + "title": "ExecSessionRequest", + "type": "object" + }, + "ExecSessionResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_EXEC_SESSION`]: the node-local run\nid the dispatch spawned and its terminal status.", + "properties": { + "status": { + "description": "Terminal status of the node-local run (e.g. `completed`, `failed`,\n`escalated`, `cancelled`, or `no-run` when nothing was dispatched).", + "type": "string" + }, + "workflow_id": { + "description": "The node-local workflow run id, when one was spawned.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "title": "ExecSessionResponse", + "type": "object" + }, + "ExecStream": { + "description": "Which output stream an [`ExecNotification`] delta belongs to.", + "oneOf": [ + { + "const": "stdout", + "description": "Standard output.", + "type": "string" + }, + { + "const": "stderr", + "description": "Standard error.", + "type": "string" + } + ], + "title": "ExecStream" + }, + "GetNodeRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_GET`].", + "properties": { + "id": { + "description": "Substrate id or name of the node to describe.", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "GetNodeRequest", + "type": "object" + }, + "GetNodeResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_GET`].", + "properties": { + "node": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentNode" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The node, or null when no node matched." + } + }, + "title": "GetNodeResponse", + "type": "object" + }, + "HarnessCommand": { + "description": "A command to run inside a prepared environment.\n\nThis is the harness invocation the provider layer would otherwise run\ndirectly on the host; the environment plugin runs it inside the prepared\ncontext instead.", + "properties": { + "args": { + "description": "Arguments passed to [`Self::program`], not including `argv[0]`.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "Working directory for the command, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, runs in the primary\nrepo's directory (or the workspace root when there is no primary repo).", + "type": [ + "string", + "null" + ] + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "description": "Extra environment variables for this command, merged over (and\noverriding) [`EnvironmentSpec::env`].", + "type": "object" + }, + "program": { + "description": "Executable to run (looked up on the environment's `PATH` unless\nabsolute).", + "type": "string" + } + }, + "required": [ + "program" + ], + "title": "HarnessCommand", + "type": "object" + }, + "ListNodesRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_LIST`] (no parameters).", + "title": "ListNodesRequest", + "type": "object" + }, + "ListNodesResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_LIST`].", + "properties": { + "nodes": { + "default": [], + "description": "Every known environment node.", + "items": { + "$ref": "#/$defs/EnvironmentNode" + }, + "type": "array" + } + }, + "title": "ListNodesResponse", + "type": "object" + }, + "PrepareRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_PREPARE`].", + "properties": { + "spec": { + "$ref": "#/$defs/EnvironmentSpec", + "description": "The environment to materialize." + } + }, + "required": [ + "spec" + ], + "title": "PrepareRequest", + "type": "object" + }, + "PrepareResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_PREPARE`].", + "properties": { + "handle": { + "$ref": "#/$defs/EnvironmentHandle", + "description": "Handle to the materialized context, used for subsequent `exec` and\n`teardown` calls." + } + }, + "required": [ + "handle" + ], + "title": "PrepareResponse", + "type": "object" + }, + "ReapRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_REAP`]. With no fields set, reap\ndeletes only dead nodes (always safe — a live node is never dead).", + "properties": { + "all": { + "description": "Also reap non-dead nodes that have no live owning run (needs `force`).", + "type": "boolean" + }, + "dry_run": { + "description": "Report what WOULD be reaped without deleting anything.", + "type": "boolean" + }, + "force": { + "description": "Required alongside `all` — guards a fresh (no-liveness) reaper from\ntreating every healthy node as an orphan.", + "type": "boolean" + }, + "older_than_secs": { + "description": "Only reap nodes at least this many seconds old.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ReapRequest", + "type": "object" + }, + "ReapResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_REAP`].", + "properties": { + "deleted": { + "default": [], + "description": "Substrate ids deleted (or that WOULD be deleted, when `dry_run`).", + "items": { + "type": "string" + }, + "type": "array" + }, + "dry_run": { + "default": false, + "description": "Echoes the request's `dry_run`.", + "type": "boolean" + }, + "kept": { + "default": [], + "description": "Nodes spared.", + "items": { + "$ref": "#/$defs/EnvironmentNode" + }, + "type": "array" + } + }, + "title": "ReapResponse", + "type": "object" + }, + "RepoRef": { + "description": "A single repository in an environment's workspace (repo set).\n\nA multi-repo workspace (see the top-level `workspace:` config) checks out\nmore than one `RepoRef` under [`EnvironmentHandle::workspace_root`], each in\nits own subdirectory named by [`RepoRef::name`] (or derived from\n[`RepoRef::url`] when `name` is unset).", + "properties": { + "git_ref": { + "description": "Git ref (branch, tag, or commit) to check out. When unset, the plugin\nuses the remote's default branch.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Subdirectory name to check the repo out under, relative to\n[`EnvironmentHandle::workspace_root`]. When unset, the plugin derives it\nfrom the last path segment of [`Self::url`].", + "type": [ + "string", + "null" + ] + }, + "primary": { + "description": "True when this repo is the primary workspace repo (the one a\nsingle-repo subject maps to, and the default `cwd` for\n[`HarnessCommand`]). At most one repo in a set should be primary; when\nnone is marked, the first entry is primary.", + "type": "boolean" + }, + "url": { + "description": "Clone URL or local path for the repository. The originating environment\nplugin interprets the value (an `https://`/`git@` remote for the\nworktree/container/remote runners, or a local path for a bind-mount).", + "type": "string" + } + }, + "required": [ + "url" + ], + "title": "RepoRef", + "type": "object" + }, + "TeardownNodeRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`].", + "properties": { + "id": { + "description": "Substrate id or name of the node to destroy.", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "TeardownNodeRequest", + "type": "object" + }, + "TeardownNodeResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_TEARDOWN_NODE`]. Idempotent — an\nalready-gone node yields an empty `deleted`.", + "properties": { + "deleted": { + "default": [], + "description": "Substrate ids actually deleted.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "TeardownNodeResponse", + "type": "object" + }, + "TeardownRequest": { + "description": "Request payload for [`METHOD_ENVIRONMENT_TEARDOWN`].", + "properties": { + "handle": { + "$ref": "#/$defs/EnvironmentHandle", + "description": "The prepared context to dispose of." + } + }, + "required": [ + "handle" + ], + "title": "TeardownRequest", + "type": "object" + }, + "TeardownResponse": { + "description": "Response payload for [`METHOD_ENVIRONMENT_TEARDOWN`]. Empty on success; the\nwire-level error shape is `animus_plugin_protocol::RpcError`.", + "title": "TeardownResponse", + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "animus-environment-protocol" +} diff --git a/schemas/animus-plugin-protocol/ConversationAppendMessageRequest.json b/schemas/animus-plugin-protocol/ConversationAppendMessageRequest.json index 135bb74..1d3d0fe 100644 --- a/schemas/animus-plugin-protocol/ConversationAppendMessageRequest.json +++ b/schemas/animus-plugin-protocol/ConversationAppendMessageRequest.json @@ -19,6 +19,17 @@ "description": "The turn to append.", "$ref": "#/$defs/ChatMessage" }, + "operation_fence": { + "description": "Required for assistant messages produced by a keyed operation on a\nshared backend. Omitted for ordinary messages and legacy/local\nstores. Supporting backends advertise\n[`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`].", + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperationAppendFence" + }, + { + "type": "null" + } + ] + }, "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", "type": [ @@ -32,6 +43,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } }, "required": [ @@ -98,6 +118,29 @@ "content", "recorded_at" ] + }, + "ConversationOperationAppendFence": { + "description": "Lease identity required when a shared-authority runtime persists the\nassistant message produced by a keyed operation.\n\nA backend advertising\n[`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`] MUST append the\nmessage in one transaction whose predicate matches the authenticated\ntenant and actor, repository and conversation, caller key, operation\nid, lease token, unexpired backend-clock lease, `user_accepted` state,\nand the conversation's active operation reservation. A stale fence is\nrejected without inserting a message or changing conversation meta.", + "type": "object", + "properties": { + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "caller_key", + "operation_id", + "lease_token" + ] } } } diff --git a/schemas/animus-plugin-protocol/ConversationCreateRequest.json b/schemas/animus-plugin-protocol/ConversationCreateRequest.json index ea07ea4..c3ba12a 100644 --- a/schemas/animus-plugin-protocol/ConversationCreateRequest.json +++ b/schemas/animus-plugin-protocol/ConversationCreateRequest.json @@ -4,6 +4,13 @@ "description": "`conversation/create` request.", "type": "object", "properties": { + "agent_id": { + "description": "Canonical agent binding to stamp atomically at creation time.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "Explicit conversation id; the backend assigns one when `None`.", "type": [ @@ -12,7 +19,7 @@ ] }, "owner": { - "description": "Owner to stamp onto the new conversation's meta.", + "description": "Legacy owner compatibility assertion.\n\nAuthenticated stores MUST stamp the owner from their call context;\nwhen this field is present they may require it to match, but MUST\nnever treat it as caller authority.", "type": [ "string", "null" @@ -32,6 +39,15 @@ "null" ] }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + }, "visibility": { "description": "Initial visibility for the new conversation.", "$ref": "#/$defs/Visibility", diff --git a/schemas/animus-plugin-protocol/ConversationCreateResponse.json b/schemas/animus-plugin-protocol/ConversationCreateResponse.json index f928d6c..d8ddc96 100644 --- a/schemas/animus-plugin-protocol/ConversationCreateResponse.json +++ b/schemas/animus-plugin-protocol/ConversationCreateResponse.json @@ -14,9 +14,26 @@ ], "$defs": { "ConversationMeta": { - "description": "Conversation metadata — the continuity pointer, identity, and the\nownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\n`owner` and `visibility` use serde defaults so legacy `meta.json`\nfiles (which lack both) still deserialize as unowned + private.", + "description": "Conversation metadata — the continuity pointer, identity, and the\nimmutable ownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\nIdentity/concurrency fields use serde defaults so legacy `meta.json`\nfiles still deserialize as unbound, revision zero, unowned + private.", "type": "object", "properties": { + "active_operation_id": { + "description": "Durable operation that owns the current revision reservation.\n\n`None` means no keyed operation owns a reservation. A present id\nMUST contain 1..=128 ASCII alphanumeric or `._:-` characters.\nBackends expose it through load-meta and accept it through\nsave-meta so the owning operation can recover after a crash.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.\n`None` keeps legacy and intentionally unbound conversations neutral.", + "type": [ + "string", + "null" + ] + }, "created_at": { "description": "RFC 3339 creation timestamp.", "type": "string" @@ -40,12 +57,19 @@ ] }, "owner": { - "description": "Authenticated user id that owns this conversation. `None` = unowned\n(legacy on-disk conversations, or ones created without `--as-user`).", + "description": "Authenticated user id that owns this conversation. Authenticated\nstores stamp this from their call context and MUST NOT accept an\nordinary metadata update that changes or clears it. `None` is\nreserved for explicitly configured legacy data.", "type": [ "string", "null" ] }, + "revision": { + "description": "Monotonic optimistic-concurrency token. Legacy metas start at zero.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "session_id": { "description": "The wrapped tool's native session handle, for resume continuity.", "type": [ diff --git a/schemas/animus-plugin-protocol/ConversationDeleteRequest.json b/schemas/animus-plugin-protocol/ConversationDeleteRequest.json index 0ed9cb2..5d78559 100644 --- a/schemas/animus-plugin-protocol/ConversationDeleteRequest.json +++ b/schemas/animus-plugin-protocol/ConversationDeleteRequest.json @@ -28,6 +28,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } }, "required": [ diff --git a/schemas/animus-plugin-protocol/ConversationListRequest.json b/schemas/animus-plugin-protocol/ConversationListRequest.json index 75510a9..6487885 100644 --- a/schemas/animus-plugin-protocol/ConversationListRequest.json +++ b/schemas/animus-plugin-protocol/ConversationListRequest.json @@ -24,6 +24,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } } } diff --git a/schemas/animus-plugin-protocol/ConversationListResponse.json b/schemas/animus-plugin-protocol/ConversationListResponse.json index dfba684..de3833c 100644 --- a/schemas/animus-plugin-protocol/ConversationListResponse.json +++ b/schemas/animus-plugin-protocol/ConversationListResponse.json @@ -18,6 +18,13 @@ "description": "One-line summary returned by [`METHOD_CONVERSATION_LIST`].", "type": "object", "properties": { + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "Conversation id.", "type": "string" @@ -42,6 +49,13 @@ "null" ] }, + "revision": { + "description": "Current optimistic-concurrency token.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "title": { "description": "Title, if any.", "type": [ diff --git a/schemas/animus-plugin-protocol/ConversationLoadMessagesRequest.json b/schemas/animus-plugin-protocol/ConversationLoadMessagesRequest.json index 978765e..5ccc5d2 100644 --- a/schemas/animus-plugin-protocol/ConversationLoadMessagesRequest.json +++ b/schemas/animus-plugin-protocol/ConversationLoadMessagesRequest.json @@ -28,6 +28,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } }, "required": [ diff --git a/schemas/animus-plugin-protocol/ConversationLoadMetaRequest.json b/schemas/animus-plugin-protocol/ConversationLoadMetaRequest.json index 87f4f24..dbfbeea 100644 --- a/schemas/animus-plugin-protocol/ConversationLoadMetaRequest.json +++ b/schemas/animus-plugin-protocol/ConversationLoadMetaRequest.json @@ -28,6 +28,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } }, "required": [ diff --git a/schemas/animus-plugin-protocol/ConversationLoadMetaResponse.json b/schemas/animus-plugin-protocol/ConversationLoadMetaResponse.json index 8abdad3..fc7cd86 100644 --- a/schemas/animus-plugin-protocol/ConversationLoadMetaResponse.json +++ b/schemas/animus-plugin-protocol/ConversationLoadMetaResponse.json @@ -18,9 +18,26 @@ }, "$defs": { "ConversationMeta": { - "description": "Conversation metadata — the continuity pointer, identity, and the\nownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\n`owner` and `visibility` use serde defaults so legacy `meta.json`\nfiles (which lack both) still deserialize as unowned + private.", + "description": "Conversation metadata — the continuity pointer, identity, and the\nimmutable ownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\nIdentity/concurrency fields use serde defaults so legacy `meta.json`\nfiles still deserialize as unbound, revision zero, unowned + private.", "type": "object", "properties": { + "active_operation_id": { + "description": "Durable operation that owns the current revision reservation.\n\n`None` means no keyed operation owns a reservation. A present id\nMUST contain 1..=128 ASCII alphanumeric or `._:-` characters.\nBackends expose it through load-meta and accept it through\nsave-meta so the owning operation can recover after a crash.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.\n`None` keeps legacy and intentionally unbound conversations neutral.", + "type": [ + "string", + "null" + ] + }, "created_at": { "description": "RFC 3339 creation timestamp.", "type": "string" @@ -44,12 +61,19 @@ ] }, "owner": { - "description": "Authenticated user id that owns this conversation. `None` = unowned\n(legacy on-disk conversations, or ones created without `--as-user`).", + "description": "Authenticated user id that owns this conversation. Authenticated\nstores stamp this from their call context and MUST NOT accept an\nordinary metadata update that changes or clears it. `None` is\nreserved for explicitly configured legacy data.", "type": [ "string", "null" ] }, + "revision": { + "description": "Monotonic optimistic-concurrency token. Legacy metas start at zero.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "session_id": { "description": "The wrapped tool's native session handle, for resume continuity.", "type": [ diff --git a/schemas/animus-plugin-protocol/ConversationMeta.json b/schemas/animus-plugin-protocol/ConversationMeta.json index 5bb1363..6975368 100644 --- a/schemas/animus-plugin-protocol/ConversationMeta.json +++ b/schemas/animus-plugin-protocol/ConversationMeta.json @@ -1,9 +1,26 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "ConversationMeta", - "description": "Conversation metadata — the continuity pointer, identity, and the\nownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\n`owner` and `visibility` use serde defaults so legacy `meta.json`\nfiles (which lack both) still deserialize as unowned + private.", + "description": "Conversation metadata — the continuity pointer, identity, and the\nimmutable ownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\nIdentity/concurrency fields use serde defaults so legacy `meta.json`\nfiles still deserialize as unbound, revision zero, unowned + private.", "type": "object", "properties": { + "active_operation_id": { + "description": "Durable operation that owns the current revision reservation.\n\n`None` means no keyed operation owns a reservation. A present id\nMUST contain 1..=128 ASCII alphanumeric or `._:-` characters.\nBackends expose it through load-meta and accept it through\nsave-meta so the owning operation can recover after a crash.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.\n`None` keeps legacy and intentionally unbound conversations neutral.", + "type": [ + "string", + "null" + ] + }, "created_at": { "description": "RFC 3339 creation timestamp.", "type": "string" @@ -27,12 +44,19 @@ ] }, "owner": { - "description": "Authenticated user id that owns this conversation. `None` = unowned\n(legacy on-disk conversations, or ones created without `--as-user`).", + "description": "Authenticated user id that owns this conversation. Authenticated\nstores stamp this from their call context and MUST NOT accept an\nordinary metadata update that changes or clears it. `None` is\nreserved for explicitly configured legacy data.", "type": [ "string", "null" ] }, + "revision": { + "description": "Monotonic optimistic-concurrency token. Legacy metas start at zero.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "session_id": { "description": "The wrapped tool's native session handle, for resume continuity.", "type": [ diff --git a/schemas/animus-plugin-protocol/ConversationOperation.json b/schemas/animus-plugin-protocol/ConversationOperation.json new file mode 100644 index 0000000..fd552b1 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperation.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperation", + "description": "Canonical durable operation state. Lease credentials are returned only\non an acquired begin and are not part of replay/load receipts.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status" + ], + "$defs": { + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationAcceptUserRequest.json b/schemas/animus-plugin-protocol/ConversationOperationAcceptUserRequest.json new file mode 100644 index 0000000..7d3d995 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationAcceptUserRequest.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationAcceptUserRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + }, + "user_seq": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "user_seq" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationAppendFence.json b/schemas/animus-plugin-protocol/ConversationOperationAppendFence.json new file mode 100644 index 0000000..b808333 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationAppendFence.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationAppendFence", + "description": "Lease identity required when a shared-authority runtime persists the\nassistant message produced by a keyed operation.\n\nA backend advertising\n[`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`] MUST append the\nmessage in one transaction whose predicate matches the authenticated\ntenant and actor, repository and conversation, caller key, operation\nid, lease token, unexpired backend-clock lease, `user_accepted` state,\nand the conversation's active operation reservation. A stale fence is\nrejected without inserting a message or changing conversation meta.", + "type": "object", + "properties": { + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "caller_key", + "operation_id", + "lease_token" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationBeginOutcome.json b/schemas/animus-plugin-protocol/ConversationOperationBeginOutcome.json new file mode 100644 index 0000000..b02e5a9 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationBeginOutcome.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationBeginOutcome", + "type": "string", + "enum": [ + "acquired", + "replay", + "in_progress", + "conflict" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationBeginRequest.json b/schemas/animus-plugin-protocol/ConversationOperationBeginRequest.json new file mode 100644 index 0000000..1eeda8e --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationBeginRequest.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationBeginRequest", + "description": "Fields common to every conversation-store request: tenant, project, and\nrepository identity a shared backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "request_hash": { + "type": "string" + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key", + "request_hash" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationBeginResponse.json b/schemas/animus-plugin-protocol/ConversationOperationBeginResponse.json new file mode 100644 index 0000000..d803c9e --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationBeginResponse.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationBeginResponse", + "type": "object", + "properties": { + "claim": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperationClaim" + }, + { + "type": "null" + } + ] + }, + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] + }, + "outcome": { + "$ref": "#/$defs/ConversationOperationBeginOutcome" + } + }, + "required": [ + "outcome" + ], + "$defs": { + "ConversationOperation": { + "description": "Canonical durable operation state. Lease credentials are returned only\non an acquired begin and are not part of replay/load receipts.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status" + ] + }, + "ConversationOperationBeginOutcome": { + "type": "string", + "enum": [ + "acquired", + "replay", + "in_progress", + "conflict" + ] + }, + "ConversationOperationClaim": { + "description": "Lease-bearing claim returned only to the host that owns shared authority.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "lease_expires_at": { + "type": "integer", + "format": "int64" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "recovered": { + "type": "boolean", + "default": false + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status", + "lease_token", + "lease_expires_at" + ] + }, + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationBindExecutionRequest.json b/schemas/animus-plugin-protocol/ConversationOperationBindExecutionRequest.json new file mode 100644 index 0000000..742c62d --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationBindExecutionRequest.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationBindExecutionRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "allow_rebind": { + "description": "Rebinding is permitted only for a reclaimed pending lease before\ncanonical user acceptance; ordinary claims may only bind once.", + "type": "boolean", + "default": false + }, + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "execution_hash": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "execution_hash" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationClaim.json b/schemas/animus-plugin-protocol/ConversationOperationClaim.json new file mode 100644 index 0000000..c985936 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationClaim.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationClaim", + "description": "Lease-bearing claim returned only to the host that owns shared authority.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "lease_expires_at": { + "type": "integer", + "format": "int64" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "recovered": { + "type": "boolean", + "default": false + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status", + "lease_token", + "lease_expires_at" + ], + "$defs": { + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationKey.json b/schemas/animus-plugin-protocol/ConversationOperationKey.json new file mode 100644 index 0000000..affafc3 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationKey.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationKey", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationLoadRequest.json b/schemas/animus-plugin-protocol/ConversationOperationLoadRequest.json new file mode 100644 index 0000000..7ca948c --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationLoadRequest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationLoadRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationLoadResponse.json b/schemas/animus-plugin-protocol/ConversationOperationLoadResponse.json new file mode 100644 index 0000000..eb153b5 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationLoadResponse.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationLoadResponse", + "type": "object", + "properties": { + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "ConversationOperation": { + "description": "Canonical durable operation state. Lease credentials are returned only\non an acquired begin and are not part of replay/load receipts.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status" + ] + }, + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationMutationResponse.json b/schemas/animus-plugin-protocol/ConversationOperationMutationResponse.json new file mode 100644 index 0000000..9c22da4 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationMutationResponse.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationMutationResponse", + "type": "object", + "properties": { + "changed": { + "type": "boolean" + }, + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "changed" + ], + "$defs": { + "ConversationOperation": { + "description": "Canonical durable operation state. Lease credentials are returned only\non an acquired begin and are not part of replay/load receipts.", + "type": "object", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status" + ] + }, + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationReleaseRequest.json b/schemas/animus-plugin-protocol/ConversationOperationReleaseRequest.json new file mode 100644 index 0000000..16f795c --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationReleaseRequest.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationReleaseRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationRenewRequest.json b/schemas/animus-plugin-protocol/ConversationOperationRenewRequest.json new file mode 100644 index 0000000..8a01236 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationRenewRequest.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationRenewRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationStatus.json b/schemas/animus-plugin-protocol/ConversationOperationStatus.json new file mode 100644 index 0000000..0003693 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationStatus.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationStatus", + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] +} diff --git a/schemas/animus-plugin-protocol/ConversationOperationTerminalizeRequest.json b/schemas/animus-plugin-protocol/ConversationOperationTerminalizeRequest.json new file mode 100644 index 0000000..53f6cd9 --- /dev/null +++ b/schemas/animus-plugin-protocol/ConversationOperationTerminalizeRequest.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ConversationOperationTerminalizeRequest", + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "type": "object", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "assistant_seq": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "caller_key": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "status" + ], + "$defs": { + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "type": "string", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ] + } + } +} diff --git a/schemas/animus-plugin-protocol/ConversationSaveMetaRequest.json b/schemas/animus-plugin-protocol/ConversationSaveMetaRequest.json index 77e894a..584656f 100644 --- a/schemas/animus-plugin-protocol/ConversationSaveMetaRequest.json +++ b/schemas/animus-plugin-protocol/ConversationSaveMetaRequest.json @@ -11,8 +11,17 @@ "null" ] }, + "expected_revision": { + "description": "Apply only when the stored meta still has this revision. Backends\nmust enforce this atomically with the write (compare-and-swap).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, "meta": { - "description": "The meta to persist.", + "description": "The meta to persist. `meta.owner` is an immutable assertion: an\nordinary save MUST NOT change or clear the stored owner.", "$ref": "#/$defs/ConversationMeta" }, "project_root": { @@ -28,6 +37,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } }, "required": [ @@ -35,9 +53,26 @@ ], "$defs": { "ConversationMeta": { - "description": "Conversation metadata — the continuity pointer, identity, and the\nownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\n`owner` and `visibility` use serde defaults so legacy `meta.json`\nfiles (which lack both) still deserialize as unowned + private.", + "description": "Conversation metadata — the continuity pointer, identity, and the\nimmutable ownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\nIdentity/concurrency fields use serde defaults so legacy `meta.json`\nfiles still deserialize as unbound, revision zero, unowned + private.", "type": "object", "properties": { + "active_operation_id": { + "description": "Durable operation that owns the current revision reservation.\n\n`None` means no keyed operation owns a reservation. A present id\nMUST contain 1..=128 ASCII alphanumeric or `._:-` characters.\nBackends expose it through load-meta and accept it through\nsave-meta so the owning operation can recover after a crash.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.\n`None` keeps legacy and intentionally unbound conversations neutral.", + "type": [ + "string", + "null" + ] + }, "created_at": { "description": "RFC 3339 creation timestamp.", "type": "string" @@ -61,12 +96,19 @@ ] }, "owner": { - "description": "Authenticated user id that owns this conversation. `None` = unowned\n(legacy on-disk conversations, or ones created without `--as-user`).", + "description": "Authenticated user id that owns this conversation. Authenticated\nstores stamp this from their call context and MUST NOT accept an\nordinary metadata update that changes or clears it. `None` is\nreserved for explicitly configured legacy data.", "type": [ "string", "null" ] }, + "revision": { + "description": "Monotonic optimistic-concurrency token. Legacy metas start at zero.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "session_id": { "description": "The wrapped tool's native session handle, for resume continuity.", "type": [ diff --git a/schemas/animus-plugin-protocol/ConversationScope.json b/schemas/animus-plugin-protocol/ConversationScope.json index cc320d8..f3d6e47 100644 --- a/schemas/animus-plugin-protocol/ConversationScope.json +++ b/schemas/animus-plugin-protocol/ConversationScope.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "ConversationScope", - "description": "Fields common to every conversation-store request: the project + scope\nidentity a multi-tenant backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", + "description": "Fields common to every conversation-store request: tenant, project, and\nrepository identity a shared backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", "type": "object", "properties": { "project_root": { @@ -17,6 +17,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "type": [ + "string", + "null" + ], + "maxLength": 128, + "minLength": 1 } } } diff --git a/schemas/animus-plugin-protocol/ConversationSummary.json b/schemas/animus-plugin-protocol/ConversationSummary.json index 2525f2f..e1ca9c7 100644 --- a/schemas/animus-plugin-protocol/ConversationSummary.json +++ b/schemas/animus-plugin-protocol/ConversationSummary.json @@ -4,6 +4,13 @@ "description": "One-line summary returned by [`METHOD_CONVERSATION_LIST`].", "type": "object", "properties": { + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "Conversation id.", "type": "string" @@ -28,6 +35,13 @@ "null" ] }, + "revision": { + "description": "Current optimistic-concurrency token.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, "title": { "description": "Title, if any.", "type": [ diff --git a/schemas/animus-plugin-protocol/PluginManifest.json b/schemas/animus-plugin-protocol/PluginManifest.json index 6d2b065..5f66f7a 100644 --- a/schemas/animus-plugin-protocol/PluginManifest.json +++ b/schemas/animus-plugin-protocol/PluginManifest.json @@ -51,6 +51,13 @@ "description": "Protocol version the plugin was built against.", "type": "string" }, + "supports_mcp": { + "description": "Whether this plugin consumes host-injected MCP servers.\n\nFirst-class, plugin-DECLARED capability the kernel reads instead of\nhardcoding per-tool behavior in name tables (REQUIREMENT-039). A\nprovider plugin sets this to advertise whether it accepts the\nhost-supplied MCP server set (profile/skill MCP endpoints injected via\nthe run request); the kernel gates MCP injection on the declared value\nrather than a built-in allow-list keyed on the tool name.\n\nThis is the proof-of-pattern field for a growing family of declared\nkernel-behavior capabilities (launch template, permission-mode flag,\nreasoning-effort, default model, ...) that will migrate off the kernel's\nhardcoded name tables onto the manifest over subsequent slices.\n\nBack-compat: `None` means \"undeclared\" — plugins built against earlier\nprotocol versions omit the field entirely, and the kernel applies its\nhistorical default (MCP-capable for provider plugins). Only an explicit\n`Some(false)` opts a provider out of MCP injection.", + "type": [ + "boolean", + "null" + ] + }, "version": { "description": "Plugin semver.", "type": "string" diff --git a/schemas/animus-plugin-protocol/_all.json b/schemas/animus-plugin-protocol/_all.json index 9306a93..8aa2daa 100644 --- a/schemas/animus-plugin-protocol/_all.json +++ b/schemas/animus-plugin-protocol/_all.json @@ -79,6 +79,17 @@ "$ref": "#/$defs/ChatMessage", "description": "The turn to append." }, + "operation_fence": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperationAppendFence" + }, + { + "type": "null" + } + ], + "description": "Required for assistant messages produced by a keyed operation on a\nshared backend. Omitted for ordinary messages and legacy/local\nstores. Supporting backends advertise\n[`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`]." + }, "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", "type": [ @@ -92,6 +103,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ @@ -109,6 +129,13 @@ "ConversationCreateRequest": { "description": "`conversation/create` request.", "properties": { + "agent_id": { + "description": "Canonical agent binding to stamp atomically at creation time.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "Explicit conversation id; the backend assigns one when `None`.", "type": [ @@ -117,12 +144,232 @@ ] }, "owner": { - "description": "Owner to stamp onto the new conversation's meta.", + "description": "Legacy owner compatibility assertion.\n\nAuthenticated stores MUST stamp the owner from their call context;\nwhen this field is present they may require it to match, but MUST\nnever treat it as caller authority.", + "type": [ + "string", + "null" + ] + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "visibility": { + "$ref": "#/$defs/Visibility", + "default": "private", + "description": "Initial visibility for the new conversation." + } + }, + "title": "ConversationCreateRequest", + "type": "object" + }, + "ConversationCreateResponse": { + "description": "`conversation/create` response.", + "properties": { + "meta": { + "$ref": "#/$defs/ConversationMeta", + "description": "Meta of the freshly-created conversation." + } + }, + "required": [ + "meta" + ], + "title": "ConversationCreateResponse", + "type": "object" + }, + "ConversationDeleteRequest": { + "description": "`conversation/delete` request.", + "properties": { + "as_user": { + "description": "Acting user id, when known. A backend MAY use it to authorize the\ndelete. `None` for unscoped/admin access.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Conversation id to delete.", + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "title": "ConversationDeleteRequest", + "type": "object" + }, + "ConversationDeleteResponse": { + "description": "`conversation/delete` response (empty; delete is idempotent).", + "title": "ConversationDeleteResponse", + "type": "object" + }, + "ConversationListRequest": { + "description": "`conversation/list` request.", + "properties": { + "as_user": { + "description": "When set, the backend returns conversations owned by this user id\nPLUS any [`Visibility::Shared`] ones. When `None`, all\nconversations are returned (legacy/admin view). A backend without\nauth context may ignore this and return everything.", + "type": [ + "string", + "null" + ] + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "title": "ConversationListRequest", + "type": "object" + }, + "ConversationListResponse": { + "description": "`conversation/list` response, summaries newest-first.", + "properties": { + "conversations": { + "default": [], + "description": "Conversation summaries, most-recently-updated first.", + "items": { + "$ref": "#/$defs/ConversationSummary" + }, + "type": "array" + } + }, + "title": "ConversationListResponse", + "type": "object" + }, + "ConversationLoadMessagesRequest": { + "description": "`conversation/load_messages` request.", + "properties": { + "as_user": { + "description": "Acting user id, when known. A backend MAY use it to authorize the\nread. `None` for unscoped/admin access.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Conversation id whose messages to read.", + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "title": "ConversationLoadMessagesRequest", + "type": "object" + }, + "ConversationLoadMessagesResponse": { + "description": "`conversation/load_messages` response.", + "properties": { + "messages": { + "default": [], + "description": "The ordered turn history.", + "items": { + "$ref": "#/$defs/ChatMessage" + }, + "type": "array" + } + }, + "title": "ConversationLoadMessagesResponse", + "type": "object" + }, + "ConversationLoadMetaRequest": { + "description": "`conversation/load_meta` request.", + "properties": { + "as_user": { + "description": "Acting user id, when known. A backend MAY use it to authorize the\nread (e.g. deny a private conversation owned by another user).\n`None` for unscoped/admin access.", "type": [ "string", "null" ] }, + "id": { + "description": "Conversation id to load.", + "type": "string" + }, "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", "type": [ @@ -130,48 +377,553 @@ "null" ] }, - "repo_scope": { - "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "title": "ConversationLoadMetaRequest", + "type": "object" + }, + "ConversationLoadMetaResponse": { + "description": "`conversation/load_meta` response. `meta` is `None` when the\nconversation does not exist.", + "properties": { + "meta": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationMeta" + }, + { + "type": "null" + } + ], + "description": "The conversation meta, or `None` when not found." + } + }, + "title": "ConversationLoadMetaResponse", + "type": "object" + }, + "ConversationMeta": { + "description": "Conversation metadata — the continuity pointer, identity, and the\nimmutable ownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\nIdentity/concurrency fields use serde defaults so legacy `meta.json`\nfiles still deserialize as unbound, revision zero, unowned + private.", + "properties": { + "active_operation_id": { + "description": "Durable operation that owns the current revision reservation.\n\n`None` means no keyed operation owns a reservation. A present id\nMUST contain 1..=128 ASCII alphanumeric or `._:-` characters.\nBackends expose it through load-meta and accept it through\nsave-meta so the owning operation can recover after a crash.", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": [ + "string", + "null" + ] + }, + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.\n`None` keeps legacy and intentionally unbound conversations neutral.", + "type": [ + "string", + "null" + ] + }, + "created_at": { + "description": "RFC 3339 creation timestamp.", + "type": "string" + }, + "id": { + "description": "Stable conversation id.", + "type": "string" + }, + "message_count": { + "default": 0, + "description": "Count of persisted turns (user + assistant).", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "model": { + "description": "Model used on the most recent turn.", + "type": [ + "string", + "null" + ] + }, + "owner": { + "description": "Authenticated user id that owns this conversation. Authenticated\nstores stamp this from their call context and MUST NOT accept an\nordinary metadata update that changes or clears it. `None` is\nreserved for explicitly configured legacy data.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "default": 0, + "description": "Monotonic optimistic-concurrency token. Legacy metas start at zero.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "description": "The wrapped tool's native session handle, for resume continuity.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Optional human-facing title.", + "type": [ + "string", + "null" + ] + }, + "tool": { + "description": "Wrapped tool that currently owns the native session. `None` until\nthe first turn completes.", + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "description": "RFC 3339 timestamp of the most recent turn.", + "type": "string" + }, + "visibility": { + "$ref": "#/$defs/Visibility", + "default": "private", + "description": "Visibility. Defaults to [`Visibility::Private`] for legacy metas." + } + }, + "required": [ + "id", + "created_at", + "updated_at" + ], + "title": "ConversationMeta", + "type": "object" + }, + "ConversationOperation": { + "description": "Canonical durable operation state. Lease credentials are returned only\non an acquired begin and are not part of replay/load receipts.", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status" + ], + "title": "ConversationOperation", + "type": "object" + }, + "ConversationOperationAcceptUserRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "user_seq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "user_seq" + ], + "title": "ConversationOperationAcceptUserRequest", + "type": "object" + }, + "ConversationOperationAppendFence": { + "description": "Lease identity required when a shared-authority runtime persists the\nassistant message produced by a keyed operation.\n\nA backend advertising\n[`CAPABILITY_CONVERSATION_OPERATION_FENCED_APPEND_V1`] MUST append the\nmessage in one transaction whose predicate matches the authenticated\ntenant and actor, repository and conversation, caller key, operation\nid, lease token, unexpired backend-clock lease, `user_accepted` state,\nand the conversation's active operation reservation. A stale fence is\nrejected without inserting a message or changing conversation meta.", + "properties": { + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "caller_key", + "operation_id", + "lease_token" + ], + "title": "ConversationOperationAppendFence", + "type": "object" + }, + "ConversationOperationBeginOutcome": { + "enum": [ + "acquired", + "replay", + "in_progress", + "conflict" + ], + "title": "ConversationOperationBeginOutcome", + "type": "string" + }, + "ConversationOperationBeginRequest": { + "description": "Fields common to every conversation-store request: tenant, project, and\nrepository identity a shared backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", + "properties": { + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "request_hash": { + "type": "string" + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "conversation_id", + "caller_key", + "request_hash" + ], + "title": "ConversationOperationBeginRequest", + "type": "object" + }, + "ConversationOperationBeginResponse": { + "properties": { + "claim": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperationClaim" + }, + { + "type": "null" + } + ] + }, + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] + }, + "outcome": { + "$ref": "#/$defs/ConversationOperationBeginOutcome" + } + }, + "required": [ + "outcome" + ], + "title": "ConversationOperationBeginResponse", + "type": "object" + }, + "ConversationOperationBindExecutionRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", + "properties": { + "allow_rebind": { + "default": false, + "description": "Rebinding is permitted only for a reclaimed pending lease before\ncanonical user acceptance; ordinary claims may only bind once.", + "type": "boolean" + }, + "as_user": { + "type": [ + "string", + "null" + ] + }, + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "execution_hash": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", + "type": [ + "string", + "null" + ] + }, + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", + "type": [ + "string", + "null" + ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "execution_hash" + ], + "title": "ConversationOperationBindExecutionRequest", + "type": "object" + }, + "ConversationOperationClaim": { + "description": "Lease-bearing claim returned only to the host that owns shared authority.", + "properties": { + "assistant_message_id": { + "type": "string" + }, + "assistant_seq": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "caller_key": { + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { + "type": [ + "string", + "null" + ] + }, + "error_message": { + "type": [ + "string", + "null" + ] + }, + "execution_hash": { + "type": [ + "string", + "null" + ] + }, + "lease_expires_at": { + "format": "int64", + "type": "integer" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "recovered": { + "default": false, + "type": "boolean" + }, + "status": { + "$ref": "#/$defs/ConversationOperationStatus" + }, + "user_message_id": { + "type": "string" + }, + "user_seq": { + "format": "uint64", + "minimum": 0, "type": [ - "string", + "integer", "null" ] - }, - "visibility": { - "$ref": "#/$defs/Visibility", - "default": "private", - "description": "Initial visibility for the new conversation." - } - }, - "title": "ConversationCreateRequest", - "type": "object" - }, - "ConversationCreateResponse": { - "description": "`conversation/create` response.", - "properties": { - "meta": { - "$ref": "#/$defs/ConversationMeta", - "description": "Meta of the freshly-created conversation." } }, "required": [ - "meta" + "operation_id", + "conversation_id", + "caller_key", + "user_message_id", + "assistant_message_id", + "status", + "lease_token", + "lease_expires_at" ], - "title": "ConversationCreateResponse", + "title": "ConversationOperationClaim", "type": "object" }, - "ConversationDeleteRequest": { - "description": "`conversation/delete` request.", + "ConversationOperationKey": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", "properties": { "as_user": { - "description": "Acting user id, when known. A backend MAY use it to authorize the\ndelete. `None` for unscoped/admin access.", "type": [ "string", "null" ] }, - "id": { - "description": "Conversation id to delete.", + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { "type": "string" }, "project_root": { @@ -187,29 +939,42 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ - "id" + "conversation_id", + "caller_key" ], - "title": "ConversationDeleteRequest", - "type": "object" - }, - "ConversationDeleteResponse": { - "description": "`conversation/delete` response (empty; delete is idempotent).", - "title": "ConversationDeleteResponse", + "title": "ConversationOperationKey", "type": "object" }, - "ConversationListRequest": { - "description": "`conversation/list` request.", + "ConversationOperationLoadRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", "properties": { "as_user": { - "description": "When set, the backend returns conversations owned by this user id\nPLUS any [`Visibility::Shared`] ones. When `None`, all\nconversations are returned (legacy/admin view). A backend without\nauth context may ignore this and return everything.", "type": [ "string", "null" ] }, + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", "type": [ @@ -223,38 +988,84 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, - "title": "ConversationListRequest", + "required": [ + "conversation_id", + "caller_key" + ], + "title": "ConversationOperationLoadRequest", "type": "object" }, - "ConversationListResponse": { - "description": "`conversation/list` response, summaries newest-first.", + "ConversationOperationLoadResponse": { "properties": { - "conversations": { - "default": [], - "description": "Conversation summaries, most-recently-updated first.", - "items": { - "$ref": "#/$defs/ConversationSummary" - }, - "type": "array" + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] } }, - "title": "ConversationListResponse", + "title": "ConversationOperationLoadResponse", "type": "object" }, - "ConversationLoadMessagesRequest": { - "description": "`conversation/load_messages` request.", + "ConversationOperationMutationResponse": { + "properties": { + "changed": { + "type": "boolean" + }, + "operation": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationOperation" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "changed" + ], + "title": "ConversationOperationMutationResponse", + "type": "object" + }, + "ConversationOperationReleaseRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", "properties": { "as_user": { - "description": "Acting user id, when known. A backend MAY use it to authorize the\nread. `None` for unscoped/admin access.", "type": [ "string", "null" ] }, - "id": { - "description": "Conversation id whose messages to read.", + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { "type": "string" }, "project_root": { @@ -270,41 +1081,48 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ - "id" + "conversation_id", + "caller_key", + "operation_id", + "lease_token" ], - "title": "ConversationLoadMessagesRequest", - "type": "object" - }, - "ConversationLoadMessagesResponse": { - "description": "`conversation/load_messages` response.", - "properties": { - "messages": { - "default": [], - "description": "The ordered turn history.", - "items": { - "$ref": "#/$defs/ChatMessage" - }, - "type": "array" - } - }, - "title": "ConversationLoadMessagesResponse", + "title": "ConversationOperationReleaseRequest", "type": "object" }, - "ConversationLoadMetaRequest": { - "description": "`conversation/load_meta` request.", + "ConversationOperationRenewRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", "properties": { "as_user": { - "description": "Acting user id, when known. A backend MAY use it to authorize the\nread (e.g. deny a private conversation owned by another user).\n`None` for unscoped/admin access.", "type": [ "string", "null" ] }, - "id": { - "description": "Conversation id to load.", + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "lease_token": { + "type": "string" + }, + "operation_id": { "type": "string" }, "project_root": { @@ -320,101 +1138,117 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ - "id" + "conversation_id", + "caller_key", + "operation_id", + "lease_token" ], - "title": "ConversationLoadMetaRequest", + "title": "ConversationOperationRenewRequest", "type": "object" }, - "ConversationLoadMetaResponse": { - "description": "`conversation/load_meta` response. `meta` is `None` when the\nconversation does not exist.", - "properties": { - "meta": { - "anyOf": [ - { - "$ref": "#/$defs/ConversationMeta" - }, - { - "type": "null" - } - ], - "description": "The conversation meta, or `None` when not found." - } - }, - "title": "ConversationLoadMetaResponse", - "type": "object" + "ConversationOperationStatus": { + "description": "Durable lifecycle state for an idempotent application chat send.", + "enum": [ + "pending", + "user_accepted", + "completed", + "assistant_failed", + "assistant_interrupted" + ], + "title": "ConversationOperationStatus", + "type": "string" }, - "ConversationMeta": { - "description": "Conversation metadata — the continuity pointer, identity, and the\nownership/visibility fields that power per-user history.\n\nThe shape matches the kernel's on-disk `meta.json` exactly so existing\nfilesystem conversations and plugin-backed ones are interchangeable.\n`owner` and `visibility` use serde defaults so legacy `meta.json`\nfiles (which lack both) still deserialize as unowned + private.", + "ConversationOperationTerminalizeRequest": { + "description": "Shared key used by load and all lease-owned mutations. The backend\nstill derives actor and tenant authority from the authenticated context.", "properties": { - "created_at": { - "description": "RFC 3339 creation timestamp.", - "type": "string" - }, - "id": { - "description": "Stable conversation id.", - "type": "string" + "as_user": { + "type": [ + "string", + "null" + ] }, - "message_count": { - "default": 0, - "description": "Count of persisted turns (user + assistant).", + "assistant_seq": { "format": "uint64", "minimum": 0, - "type": "integer" - }, - "model": { - "description": "Model used on the most recent turn.", "type": [ - "string", + "integer", "null" ] }, - "owner": { - "description": "Authenticated user id that owns this conversation. `None` = unowned\n(legacy on-disk conversations, or ones created without `--as-user`).", + "caller_key": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._:-]+$", + "type": "string" + }, + "conversation_id": { + "type": "string" + }, + "error_code": { "type": [ "string", "null" ] }, - "session_id": { - "description": "The wrapped tool's native session handle, for resume continuity.", + "error_message": { "type": [ "string", "null" ] }, - "title": { - "description": "Optional human-facing title.", + "lease_token": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "project_root": { + "description": "Absolute project root path of the calling CLI/daemon.", "type": [ "string", "null" ] }, - "tool": { - "description": "Wrapped tool that currently owns the native session. `None` until\nthe first turn completes.", + "repo_scope": { + "description": "Repository scope id (see `protocol::repository_scope_for_path`).", "type": [ "string", "null" ] }, - "updated_at": { - "description": "RFC 3339 timestamp of the most recent turn.", - "type": "string" + "status": { + "$ref": "#/$defs/ConversationOperationStatus" }, - "visibility": { - "$ref": "#/$defs/Visibility", - "default": "private", - "description": "Visibility. Defaults to [`Visibility::Private`] for legacy metas." + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ - "id", - "created_at", - "updated_at" + "conversation_id", + "caller_key", + "operation_id", + "lease_token", + "status" ], - "title": "ConversationMeta", + "title": "ConversationOperationTerminalizeRequest", "type": "object" }, "ConversationSaveMetaRequest": { @@ -427,9 +1261,18 @@ "null" ] }, + "expected_revision": { + "description": "Apply only when the stored meta still has this revision. Backends\nmust enforce this atomically with the write (compare-and-swap).", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, "meta": { "$ref": "#/$defs/ConversationMeta", - "description": "The meta to persist." + "description": "The meta to persist. `meta.owner` is an immutable assertion: an\nordinary save MUST NOT change or clear the stored owner." }, "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", @@ -444,6 +1287,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "required": [ @@ -458,7 +1310,7 @@ "type": "object" }, "ConversationScope": { - "description": "Fields common to every conversation-store request: the project + scope\nidentity a multi-tenant backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", + "description": "Fields common to every conversation-store request: tenant, project, and\nrepository identity a shared backend partitions on. Flattened into each\nrequest so the wire payload stays flat.", "properties": { "project_root": { "description": "Absolute project root path of the calling CLI/daemon.", @@ -473,6 +1325,15 @@ "string", "null" ] + }, + "tenant_id": { + "description": "Opaque server-selected tenant/workspace partition key.\n\nThe authenticated application layer derives this value from its\nsession and forwards the same value in the transport actor. A user\nsupplied workspace id is not authoritative. Authenticated stores\nMUST fail closed if this field is absent or disagrees with the\ntransport-asserted actor tenant. Omission remains wire-compatible\nonly for explicitly configured single-tenant legacy stores.", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] } }, "title": "ConversationScope", @@ -481,6 +1342,13 @@ "ConversationSummary": { "description": "One-line summary returned by [`METHOD_CONVERSATION_LIST`].", "properties": { + "agent_id": { + "description": "Canonical configured agent profile bound to this conversation.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "Conversation id.", "type": "string" @@ -505,6 +1373,13 @@ "null" ] }, + "revision": { + "default": 0, + "description": "Current optimistic-concurrency token.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, "title": { "description": "Title, if any.", "type": [ @@ -908,6 +1783,13 @@ "description": "Protocol version the plugin was built against.", "type": "string" }, + "supports_mcp": { + "description": "Whether this plugin consumes host-injected MCP servers.\n\nFirst-class, plugin-DECLARED capability the kernel reads instead of\nhardcoding per-tool behavior in name tables (REQUIREMENT-039). A\nprovider plugin sets this to advertise whether it accepts the\nhost-supplied MCP server set (profile/skill MCP endpoints injected via\nthe run request); the kernel gates MCP injection on the declared value\nrather than a built-in allow-list keyed on the tool name.\n\nThis is the proof-of-pattern field for a growing family of declared\nkernel-behavior capabilities (launch template, permission-mode flag,\nreasoning-effort, default model, ...) that will migrate off the kernel's\nhardcoded name tables onto the manifest over subsequent slices.\n\nBack-compat: `None` means \"undeclared\" — plugins built against earlier\nprotocol versions omit the field entirely, and the kernel applies its\nhistorical default (MCP-capable for provider plugins). Only an explicit\n`Some(false)` opts a provider out of MCP injection.", + "type": [ + "boolean", + "null" + ] + }, "version": { "description": "Plugin semver.", "type": "string" diff --git a/schemas/animus-queue-protocol/QueueEnqueueRequest.json b/schemas/animus-queue-protocol/QueueEnqueueRequest.json index 5815e68..6a0d0cb 100644 --- a/schemas/animus-queue-protocol/QueueEnqueueRequest.json +++ b/schemas/animus-queue-protocol/QueueEnqueueRequest.json @@ -29,10 +29,48 @@ "subject_dispatch" ], "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, "SubjectDispatch": { "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "type": "object", "properties": { + "actor": { + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -49,8 +87,15 @@ "format": "date-time" }, "subject": { - "description": "The subject this dispatch targets.", - "$ref": "#/$defs/SubjectRef" + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize.", + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ] }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -69,7 +114,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-queue-protocol/QueueEntry.json b/schemas/animus-queue-protocol/QueueEntry.json index aceb89b..d34872a 100644 --- a/schemas/animus-queue-protocol/QueueEntry.json +++ b/schemas/animus-queue-protocol/QueueEntry.json @@ -77,10 +77,48 @@ "enqueued_at" ], "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, "SubjectDispatch": { "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "type": "object", "properties": { + "actor": { + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -97,8 +135,15 @@ "format": "date-time" }, "subject": { - "description": "The subject this dispatch targets.", - "$ref": "#/$defs/SubjectRef" + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize.", + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ] }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -117,7 +162,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-queue-protocol/QueueLeaseResponse.json b/schemas/animus-queue-protocol/QueueLeaseResponse.json index a7fcb56..ffbe9d0 100644 --- a/schemas/animus-queue-protocol/QueueLeaseResponse.json +++ b/schemas/animus-queue-protocol/QueueLeaseResponse.json @@ -16,6 +16,33 @@ "leased" ], "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, "QueueEntry": { "description": "A queue entry shape returned by list / lease.", "type": "object", @@ -97,6 +124,17 @@ "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "type": "object", "properties": { + "actor": { + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -113,8 +151,15 @@ "format": "date-time" }, "subject": { - "description": "The subject this dispatch targets.", - "$ref": "#/$defs/SubjectRef" + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize.", + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ] }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -133,7 +178,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-queue-protocol/QueueListResponse.json b/schemas/animus-queue-protocol/QueueListResponse.json index fd9af03..7f83d39 100644 --- a/schemas/animus-queue-protocol/QueueListResponse.json +++ b/schemas/animus-queue-protocol/QueueListResponse.json @@ -28,6 +28,33 @@ "stats" ], "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, "QueueEntry": { "description": "A queue entry shape returned by list / lease.", "type": "object", @@ -152,6 +179,17 @@ "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "type": "object", "properties": { + "actor": { + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -168,8 +206,15 @@ "format": "date-time" }, "subject": { - "description": "The subject this dispatch targets.", - "$ref": "#/$defs/SubjectRef" + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize.", + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ] }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -188,7 +233,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-queue-protocol/_all.json b/schemas/animus-queue-protocol/_all.json index f1e6b32..cecfc5b 100644 --- a/schemas/animus-queue-protocol/_all.json +++ b/schemas/animus-queue-protocol/_all.json @@ -1,5 +1,32 @@ { "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, "QueueCapabilities": { "description": "Capability flags for queue plugins.", "properties": { @@ -526,6 +553,17 @@ "SubjectDispatch": { "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent." + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -542,8 +580,15 @@ "type": "string" }, "subject": { - "$ref": "#/$defs/SubjectRef", - "description": "The subject this dispatch targets." + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ], + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize." }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -562,7 +607,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-subject-protocol/SubjectCreateRequestV2.json b/schemas/animus-subject-protocol/SubjectCreateRequestV2.json new file mode 100644 index 0000000..9247b92 --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectCreateRequestV2.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectCreateRequestV2", + "description": "Actor-scoped v2 create request.\n\n`payload` is backend-defined so dynamic subject kinds retain their\nschema-driven fields while the authentication envelope stays typed.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "kind": { + "description": "Kind for the canonical `subject/v2/create` form. A kind-prefixed\nmethod may omit this and derive it from the method.", + "type": [ + "string", + "null" + ] + }, + "payload": { + "description": "Backend-defined create fields.", + "default": null + } + }, + "required": [ + "context" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectDeleteRequestV2.json b/schemas/animus-subject-protocol/SubjectDeleteRequestV2.json new file mode 100644 index 0000000..ceaf774 --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectDeleteRequestV2.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectDeleteRequestV2", + "description": "Actor-scoped v2 delete request.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "id": { + "description": "Subject identifier.", + "$ref": "#/$defs/SubjectId" + } + }, + "required": [ + "context", + "id" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectId": { + "description": "Backend-qualified identifier for a subject.\n\nConvention is `\":\"`, e.g. `\"linear:ENG-123\"`,\n`\"jira:PROJ-456\"`, `\"github:owner/repo#789\"`, `\"native:TASK-001\"`. The\ndaemon treats the value as opaque; only the originating backend\ninterprets the native portion.\n\nThe `backend:` prefix is reserved. Plugin authors should always emit\nprefixed ids so cross-backend collisions are impossible.", + "type": "string" + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectGetRequestV2.json b/schemas/animus-subject-protocol/SubjectGetRequestV2.json new file mode 100644 index 0000000..abc9194 --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectGetRequestV2.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectGetRequestV2", + "description": "Actor-scoped v2 get request.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "id": { + "description": "Subject identifier.", + "$ref": "#/$defs/SubjectId" + } + }, + "required": [ + "context", + "id" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectId": { + "description": "Backend-qualified identifier for a subject.\n\nConvention is `\":\"`, e.g. `\"linear:ENG-123\"`,\n`\"jira:PROJ-456\"`, `\"github:owner/repo#789\"`, `\"native:TASK-001\"`. The\ndaemon treats the value as opaque; only the originating backend\ninterprets the native portion.\n\nThe `backend:` prefix is reserved. Plugin authors should always emit\nprefixed ids so cross-backend collisions are impossible.", + "type": "string" + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectListRequestV2.json b/schemas/animus-subject-protocol/SubjectListRequestV2.json new file mode 100644 index 0000000..9d04151 --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectListRequestV2.json @@ -0,0 +1,199 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectListRequestV2", + "description": "Actor-scoped v2 list request.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "filter": { + "description": "Subject filters. Ownership is always an additional backend-enforced\npredicate and cannot be overridden by this filter.", + "$ref": "#/$defs/SubjectFilter", + "default": {} + } + }, + "required": [ + "context" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectFilter": { + "description": "Filter passed to `subject/list`.\n\nAll fields are optional and combined with AND semantics. Empty `Vec`\nfields mean \"no constraint on this dimension\". `cursor` is opaque to the\ndaemon — backends issue and accept their own pagination tokens.", + "type": "object", + "properties": { + "assignee": { + "description": "Match subjects assigned to one of these identifiers.", + "type": "array", + "items": { + "type": "string" + } + }, + "cursor": { + "description": "Pagination cursor returned by a prior `subject/list` call.", + "type": [ + "string", + "null" + ] + }, + "dispatch_label": { + "description": "Match subjects whose dispatch label (resolved by the backend from\nits [`SubjectSchema::status_dispatch_hints`] table) equals this\nvalue. Workflow YAML can use this to gate a phase on a label like\n`\"code-review\"` without coupling to any one backend's vocabulary.", + "type": [ + "string", + "null" + ] + }, + "has_attachment_kind": { + "description": "Match subjects that carry at least one [`SubjectAttachment`] whose\n`kind` equals this value. Useful for `requires_document_attachment`\nstyle gates.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "description": "Match subjects whose `kind` is one of these.", + "type": "array", + "items": { + "type": "string" + } + }, + "labels_all": { + "description": "Match subjects that have all of these labels.", + "type": "array", + "items": { + "type": "string" + } + }, + "labels_any": { + "description": "Match subjects that have at least one of these labels.", + "type": "array", + "items": { + "type": "string" + } + }, + "limit": { + "description": "Suggested page size. Backends are free to clamp this.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + }, + "native_status": { + "description": "Match subjects whose backend-raw [`Subject::native_status`] equals\nthis value. Use when the normalized [`SubjectStatus`] is too coarse\n— e.g. match Linear `\"In Review\"` but not `\"In Progress\"`, which\nboth map to [`SubjectStatus::InProgress`].", + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "Match subjects whose status is one of these.", + "type": "array", + "items": { + "$ref": "#/$defs/SubjectStatus" + } + }, + "updated_since": { + "description": "Match subjects updated at or after this timestamp.", + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + }, + "SubjectStatus": { + "description": "Normalized cross-backend subject status.\n\nBackend-native states (`\"Backlog\"`, `\"In Review\"`, `\"Won't Fix\"`, ...) map\ninto one of these five via the `status_map` declared per-subject in\nworkflow YAML. The mapping lives in configuration, not code, so adding a\nnew backend never requires extending this enum.", + "oneOf": [ + { + "description": "Eligible for dispatch.", + "type": "string", + "const": "ready" + }, + { + "description": "Currently being worked by a workflow run (or by a human, in the\nupstream system).", + "type": "string", + "const": "in-progress" + }, + { + "description": "Cannot proceed; awaiting unblock.", + "type": "string", + "const": "blocked" + }, + { + "description": "Successfully completed.", + "type": "string", + "const": "done" + }, + { + "description": "Abandoned without completion.", + "type": "string", + "const": "cancelled" + } + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectRequestContext.json b/schemas/animus-subject-protocol/SubjectRequestContext.json new file mode 100644 index 0000000..53b1adf --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectRequestContext.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectRequestContext", + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectStatusRequestV2.json b/schemas/animus-subject-protocol/SubjectStatusRequestV2.json new file mode 100644 index 0000000..8e5d0fb --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectStatusRequestV2.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectStatusRequestV2", + "description": "Actor-scoped v2 status mutation request.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "id": { + "description": "Subject identifier.", + "$ref": "#/$defs/SubjectId" + }, + "status": { + "description": "Backend-native or normalized status token.", + "type": "string" + } + }, + "required": [ + "context", + "id", + "status" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectId": { + "description": "Backend-qualified identifier for a subject.\n\nConvention is `\":\"`, e.g. `\"linear:ENG-123\"`,\n`\"jira:PROJ-456\"`, `\"github:owner/repo#789\"`, `\"native:TASK-001\"`. The\ndaemon treats the value as opaque; only the originating backend\ninterprets the native portion.\n\nThe `backend:` prefix is reserved. Plugin authors should always emit\nprefixed ids so cross-backend collisions are impossible.", + "type": "string" + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/SubjectUpdateRequestV2.json b/schemas/animus-subject-protocol/SubjectUpdateRequestV2.json new file mode 100644 index 0000000..c42f4ab --- /dev/null +++ b/schemas/animus-subject-protocol/SubjectUpdateRequestV2.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SubjectUpdateRequestV2", + "description": "Actor-scoped v2 update request.", + "type": "object", + "properties": { + "context": { + "description": "Authenticated request context.", + "$ref": "#/$defs/SubjectRequestContext" + }, + "id": { + "description": "Subject identifier.", + "$ref": "#/$defs/SubjectId" + }, + "patch": { + "description": "Backend-defined update patch.", + "default": null + } + }, + "required": [ + "context", + "id" + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, + "SubjectId": { + "description": "Backend-qualified identifier for a subject.\n\nConvention is `\":\"`, e.g. `\"linear:ENG-123\"`,\n`\"jira:PROJ-456\"`, `\"github:owner/repo#789\"`, `\"native:TASK-001\"`. The\ndaemon treats the value as opaque; only the originating backend\ninterprets the native portion.\n\nThe `backend:` prefix is reserved. Plugin authors should always emit\nprefixed ids so cross-backend collisions are impossible.", + "type": "string" + }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "type": "object", + "properties": { + "actor": { + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair.", + "$ref": "#/$defs/Actor" + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ] + } + } +} diff --git a/schemas/animus-subject-protocol/_all.json b/schemas/animus-subject-protocol/_all.json index 147aa3c..13358b1 100644 --- a/schemas/animus-subject-protocol/_all.json +++ b/schemas/animus-subject-protocol/_all.json @@ -1,5 +1,32 @@ { "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, "ChangeKind": { "description": "Categorization of a subject change event.", "oneOf": [ @@ -360,6 +387,50 @@ "title": "SubjectChangedEvent", "type": "object" }, + "SubjectCreateRequestV2": { + "description": "Actor-scoped v2 create request.\n\n`payload` is backend-defined so dynamic subject kinds retain their\nschema-driven fields while the authentication envelope stays typed.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "kind": { + "description": "Kind for the canonical `subject/v2/create` form. A kind-prefixed\nmethod may omit this and derive it from the method.", + "type": [ + "string", + "null" + ] + }, + "payload": { + "default": null, + "description": "Backend-defined create fields." + } + }, + "required": [ + "context" + ], + "title": "SubjectCreateRequestV2", + "type": "object" + }, + "SubjectDeleteRequestV2": { + "description": "Actor-scoped v2 delete request.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "id": { + "$ref": "#/$defs/SubjectId", + "description": "Subject identifier." + } + }, + "required": [ + "context", + "id" + ], + "title": "SubjectDeleteRequestV2", + "type": "object" + }, "SubjectFilter": { "description": "Filter passed to `subject/list`.\n\nAll fields are optional and combined with AND semantics. Empty `Vec`\nfields mean \"no constraint on this dimension\". `cursor` is opaque to the\ndaemon — backends issue and accept their own pagination tokens.", "properties": { @@ -447,6 +518,25 @@ "title": "SubjectFilter", "type": "object" }, + "SubjectGetRequestV2": { + "description": "Actor-scoped v2 get request.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "id": { + "$ref": "#/$defs/SubjectId", + "description": "Subject identifier." + } + }, + "required": [ + "context", + "id" + ], + "title": "SubjectGetRequestV2", + "type": "object" + }, "SubjectId": { "description": "Backend-qualified identifier for a subject.\n\nConvention is `\":\"`, e.g. `\"linear:ENG-123\"`,\n`\"jira:PROJ-456\"`, `\"github:owner/repo#789\"`, `\"native:TASK-001\"`. The\ndaemon treats the value as opaque; only the originating backend\ninterprets the native portion.\n\nThe `backend:` prefix is reserved. Plugin authors should always emit\nprefixed ids so cross-backend collisions are impossible.", "title": "SubjectId", @@ -482,6 +572,25 @@ "title": "SubjectList", "type": "object" }, + "SubjectListRequestV2": { + "description": "Actor-scoped v2 list request.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "filter": { + "$ref": "#/$defs/SubjectFilter", + "default": {}, + "description": "Subject filters. Ownership is always an additional backend-enforced\npredicate and cannot be overridden by this filter." + } + }, + "required": [ + "context" + ], + "title": "SubjectListRequestV2", + "type": "object" + }, "SubjectPatch": { "description": "A patch applied to a subject via `subject/update`.\n\nAll fields are optional. Missing fields are not modified. The\ndouble-`Option` on [`SubjectPatch::assignee`] distinguishes \"not modified\"\n(`None`) from \"explicitly clear\" (`Some(None)`). Labels are partitioned\ninto add/remove sets to avoid lost-write races on the labels list as a\nwhole.", "properties": { @@ -533,6 +642,41 @@ "title": "SubjectPatch", "type": "object" }, + "SubjectRequestContext": { + "description": "Authenticated request context asserted by the application transport.\n\nThe actor is required on every v2 subject call. Correlation and\nidempotency values are opaque transport identifiers: the kernel relays\nthem and the owning backend decides how to record or enforce them.", + "properties": { + "actor": { + "$ref": "#/$defs/Actor", + "description": "Authenticated caller. Claims remain advisory; ownership is the stable\n`(user_id, tenant_id)` pair." + }, + "correlation_id": { + "description": "Optional correlation identifier spanning related requests.", + "type": [ + "string", + "null" + ] + }, + "idempotency_key": { + "description": "Optional idempotency key for a mutation.", + "type": [ + "string", + "null" + ] + }, + "request_id": { + "description": "Optional request identifier for tracing one transport request.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "actor" + ], + "title": "SubjectRequestContext", + "type": "object" + }, "SubjectSchema": { "description": "Capability declaration returned by `subject/schema`.\n\nThe daemon uses this to adapt behavior without runtime guessing — for\nexample, to skip `subject/watch` for polling-only backends, or to\npre-populate a UI with the subject's available custom-field values.", "properties": { @@ -630,6 +774,30 @@ ], "title": "SubjectStatus" }, + "SubjectStatusRequestV2": { + "description": "Actor-scoped v2 status mutation request.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "id": { + "$ref": "#/$defs/SubjectId", + "description": "Subject identifier." + }, + "status": { + "description": "Backend-native or normalized status token.", + "type": "string" + } + }, + "required": [ + "context", + "id", + "status" + ], + "title": "SubjectStatusRequestV2", + "type": "object" + }, "SubjectUnwatchRequest": { "description": "Request payload for [`METHOD_SUBJECT_UNWATCH`]. Added in v0.1.16.\n\nCarries the `watch_id` correlating with the JSON-RPC request id the\ndaemon used when it issued the original `subject/watch` call, so the\nbackend can cancel the matching watch task.", "properties": { @@ -643,6 +811,29 @@ ], "title": "SubjectUnwatchRequest", "type": "object" + }, + "SubjectUpdateRequestV2": { + "description": "Actor-scoped v2 update request.", + "properties": { + "context": { + "$ref": "#/$defs/SubjectRequestContext", + "description": "Authenticated request context." + }, + "id": { + "$ref": "#/$defs/SubjectId", + "description": "Subject identifier." + }, + "patch": { + "default": null, + "description": "Backend-defined update patch." + } + }, + "required": [ + "context", + "id" + ], + "title": "SubjectUpdateRequestV2", + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/animus-workflow-runner-protocol/WorkflowExecuteRequest.json b/schemas/animus-workflow-runner-protocol/WorkflowExecuteRequest.json index 35aa2f9..d21991e 100644 --- a/schemas/animus-workflow-runner-protocol/WorkflowExecuteRequest.json +++ b/schemas/animus-workflow-runner-protocol/WorkflowExecuteRequest.json @@ -4,6 +4,17 @@ "description": "Parameters for [`METHOD_WORKFLOW_EXECUTE`].\n\nEither `subject_dispatch` must be set OR (`subject_ref` + one of\n`task_id` / `requirement_id` / (`title` + `description`)). Generic\nsubject backends MUST use `subject_dispatch`; task and requirement\nbackends MAY use the convenience fields.", "type": "object", "properties": { + "actor": { + "description": "Transport-asserted caller identity, relayed verbatim from the daemon so\nthe runner can pass it to subject/journal/config plugins for scoping.\n`None` for system-initiated runs with no actor.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "description": { "description": "For custom ad-hoc subjects.", "type": [ @@ -116,10 +127,48 @@ } }, "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + }, "SubjectDispatch": { "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "type": "object", "properties": { + "actor": { + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent.", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -136,8 +185,15 @@ "format": "date-time" }, "subject": { - "description": "The subject this dispatch targets.", - "$ref": "#/$defs/SubjectRef" + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize.", + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ] }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -156,7 +212,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" diff --git a/schemas/animus-workflow-runner-protocol/WorkflowPhaseRunRequest.json b/schemas/animus-workflow-runner-protocol/WorkflowPhaseRunRequest.json index 67798ee..3937be8 100644 --- a/schemas/animus-workflow-runner-protocol/WorkflowPhaseRunRequest.json +++ b/schemas/animus-workflow-runner-protocol/WorkflowPhaseRunRequest.json @@ -4,6 +4,17 @@ "description": "Parameters for [`METHOD_WORKFLOW_RUN_PHASE`].", "type": "object", "properties": { + "actor": { + "description": "Transport-asserted caller identity (see [`WorkflowExecuteRequest::actor`]).", + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ] + }, "dispatch_input": { "description": "Dispatch input JSON (opaque).", "type": [ @@ -112,5 +123,34 @@ "subject_description", "phase_id", "phase_attempt" - ] + ], + "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "type": "object", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "type": "array", + "items": { + "type": "string" + } + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ] + } + } } diff --git a/schemas/animus-workflow-runner-protocol/_all.json b/schemas/animus-workflow-runner-protocol/_all.json index 1df20e2..56cb7eb 100644 --- a/schemas/animus-workflow-runner-protocol/_all.json +++ b/schemas/animus-workflow-runner-protocol/_all.json @@ -1,5 +1,32 @@ { "$defs": { + "Actor": { + "description": "An opaque, transport-asserted caller identity.\n\nConstructed by the transport after it authenticates the caller, relayed\nverbatim by the kernel, and consumed only by plugins for data scoping. See\nthe [crate-level docs](crate) for the full contract.", + "properties": { + "claims": { + "description": "Transport-asserted claims (e.g. `[\"admin\"]`). Advisory only: the kernel\nnever branches on these. A plugin MAY consult them for its own\nauthorization decisions. Omitted from the wire when empty.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tenant_id": { + "description": "Optional tenant / organization partition the user belongs to. Plugins\nuse it for multi-tenant data isolation. Omitted from the wire when\nabsent.", + "type": [ + "string", + "null" + ] + }, + "user_id": { + "description": "Stable identifier for the authenticated user. Opaque to the kernel;\nplugins use it to scope owned rows to a user.", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "type": "object" + }, "PhaseEvent": { "description": "Event emitted by the runner during a workflow. Daemon callers receive\nthe full vector in [`WorkflowExecuteResult::phase_events`]; real-time\nstreaming is deferred to v0.6.", "oneOf": [ @@ -151,6 +178,17 @@ "SubjectDispatch": { "description": "Full dispatch envelope handed off from queue / scheduler / trigger to the\nworkflow runner.\n\nCarries the subject identity (`subject`), the workflow ref to run, and\noptional input + variables + priority + trigger source + requested-at\ntimestamp. The shape is wire-compatible with the previous home in\nao-cli's `protocol` crate.", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Caller identity this dispatch runs as. `None` is a system/global run\n(the legacy default). Owner-scoped schedules attach a kernel-minted\n[`Actor`] here (see the schedule's `owner_id`); the daemon relays it to\nthe workflow runner so downstream provider + plugin channels scope to\nthe owner. Additive and back-compat: omitted from the wire when absent." + }, "input": { "description": "Optional initial input JSON for workflow variables." }, @@ -167,8 +205,15 @@ "type": "string" }, "subject": { - "$ref": "#/$defs/SubjectRef", - "description": "The subject this dispatch targets." + "anyOf": [ + { + "$ref": "#/$defs/SubjectRef" + }, + { + "type": "null" + } + ], + "description": "The subject this dispatch targets, or `None` for a genuinely\nsubjectless run — a workflow dispatched with NO bound subject (subject\ntemplate vars are simply absent downstream). Omitted from the wire when\nabsent; older payloads always carry a subject, so this stays\nbackward-tolerant on deserialize." }, "trigger_source": { "description": "Tag identifying which subsystem produced this dispatch\n(e.g., `\"ready-queue\"`, `\"trigger:slack\"`, `\"schedule:nightly\"`).", @@ -187,7 +232,6 @@ } }, "required": [ - "subject", "workflow_ref", "trigger_source", "requested_at" @@ -232,6 +276,17 @@ "WorkflowExecuteRequest": { "description": "Parameters for [`METHOD_WORKFLOW_EXECUTE`].\n\nEither `subject_dispatch` must be set OR (`subject_ref` + one of\n`task_id` / `requirement_id` / (`title` + `description`)). Generic\nsubject backends MUST use `subject_dispatch`; task and requirement\nbackends MAY use the convenience fields.", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Transport-asserted caller identity, relayed verbatim from the daemon so\nthe runner can pass it to subject/journal/config plugins for scoping.\n`None` for system-initiated runs with no actor." + }, "description": { "description": "For custom ad-hoc subjects.", "type": [ @@ -437,6 +492,17 @@ "WorkflowPhaseRunRequest": { "description": "Parameters for [`METHOD_WORKFLOW_RUN_PHASE`].", "properties": { + "actor": { + "anyOf": [ + { + "$ref": "#/$defs/Actor" + }, + { + "type": "null" + } + ], + "description": "Transport-asserted caller identity (see [`WorkflowExecuteRequest::actor`])." + }, "dispatch_input": { "description": "Dispatch input JSON (opaque).", "type": [ diff --git a/scripts/export-all-schemas.sh b/scripts/export-all-schemas.sh index 32f6f9a..7bfdc7f 100755 --- a/scripts/export-all-schemas.sh +++ b/scripts/export-all-schemas.sh @@ -20,6 +20,7 @@ cd "${WORKSPACE_ROOT}" # Each entry is "" — the bin name is "-export-schema" by # convention. CRATES=( + "animus-application-protocol" "animus-plugin-protocol" "animus-subject-protocol" "animus-provider-protocol" @@ -31,6 +32,7 @@ CRATES=( "animus-durable-store-protocol" "animus-memory-store-protocol" "animus-notifier-protocol" + "animus-environment-protocol" ) for crate in "${CRATES[@]}"; do diff --git a/spec.md b/spec.md index 1102aac..f96f62c 100644 --- a/spec.md +++ b/spec.md @@ -508,6 +508,75 @@ When [`NotifierSchema.supports_flush`](#125-notifier-types) is `true`, the daemo Returns a [`NotifierSchema`](#125-notifier-types) capability declaration (advertised `connector_kinds` plus the `supports_flush` flag). SHOULD be cheap (constant or one-shot at startup). +### 7.10 Conversation store methods + +Conversation stores persist chat metadata and messages behind the +`conversation_store` plugin kind. The canonical metadata RPCs are: + +- `conversation/load_meta` — returns `{ "meta": ConversationMeta | null }`. +- `conversation/save_meta` — accepts `{ "meta": ConversationMeta, + "expected_revision": integer | null, ...scope }`; the backend MUST compare + `expected_revision` and write the metadata atomically. + +Every conversation method carries `ConversationScope.tenant_id`, an opaque +server-selected workspace/tenant partition key of 1 through 128 characters. +The authenticated application layer selects it from the session; it is not a +user-controlled filter. A shared backend MUST include it in every conversation +and message key and MUST compare it with the transport-asserted actor tenant. +Missing or mismatched tenant context fails closed. An omitted `tenant_id` is +permitted only when an operator has explicitly configured a fixed legacy tenant; +backends MUST NOT infer a tenant from `project_root`, `repo_scope`, owner, or +conversation id. + +On `conversation/create`, authenticated backends stamp `ConversationMeta.owner` +from the transport-asserted caller. The request's legacy `owner` field is at +most a matching assertion and is never authority. `conversation/save_meta` +MUST NOT change or clear the stored owner; owner transfer requires a separate +privileged operation rather than an ordinary metadata update. + +`ConversationMeta.active_operation_id` is optional internal concurrency state. +A keyed turn sets it when reserving a revision, which lets that same durable +operation prove ownership and continue if the host crashes before appending the +user message. A backend MUST return it from `conversation/load_meta` and MUST +persist updates supplied through `conversation/save_meta` in the same atomic +compare-and-swap as `revision`. + +When present, `active_operation_id` MUST be 1 through 128 ASCII characters and +every character MUST be an ASCII letter, digit, `.`, `_`, `:`, or `-` +(`^[A-Za-z0-9._:-]+$`). Backends MUST reject invalid values. Absence means no +operation owns a revision reservation and is the default for legacy metadata. +The field is not accepted by `conversation/create` and is intentionally omitted +from `ConversationSummary` / `conversation/list`, because callers do not select +reservation ownership and list views do not need the internal identifier. + +Shared multi-host stores additionally advertise `conversation_operations_shared_v1` +and implement the `conversation/operation_*` methods. The durable operation key +is the transport-authenticated tenant and actor plus `repo_scope`, +`conversation_id`, and `caller_key`; `as_user` and `tenant_id` in the request +remain consistency assertions only. `operation_begin` atomically returns one of +`acquired`, `replay`, `in_progress`, or `conflict`. Only `acquired` returns the +opaque lease token. A terminal replay or load never exposes lease credentials. + +Every lease-owned mutation (`renew`, execution binding, pending release, user +acceptance, and terminalization) MUST atomically compare the operation id and +lease token and MUST reject an expired lease, even when no replacement claimant +has reclaimed it yet. Reclaim rotates the lease token while preserving the +operation and message ids. Execution rebind is permitted only on a reclaimed +pending operation before user acceptance. Terminalization is permitted only +after user acceptance and is immutable; callers MUST reconcile a failed write +by loading the durable receipt and MUST NOT blindly repeat provider execution. + +A shared backend used for keyed sends MUST also advertise +`conversation_operation_fenced_append_v1`. The assistant +`conversation/append_message` carries `operation_fence` with the caller key, +operation id, and opaque lease token. The backend MUST atomically predicate the +message insert on the authenticated tenant and actor, repository and +conversation, exact operation and caller key, exact lease token, an unexpired +backend-clock lease, `user_accepted` operation state, and the conversation's +matching `active_operation_id`. A stale or terminalized holder is rejected +without inserting the message. A preflight check followed by an ordinary +append is not conformant because it leaves a check/write race. + ## 8. Plugin protocol types ### 8.1 `PROTOCOL_VERSION` @@ -535,6 +604,19 @@ The `--manifest` output: } ``` +Optional fields (omitted for back-compat when unset): + +- `plugin_kinds` (`string[]`) — additional kinds a multi-kind plugin also serves. +- `env_required` (`EnvRequirement[]`) — environment variables the host must forward at spawn. +- `notification_buffer_size` (`integer`) — author hint for the broadcast channel capacity. +- `supports_mcp` (`boolean`) — since protocol **1.2.0**. First-class, plugin-DECLARED + capability: whether the plugin consumes host-injected MCP servers. The kernel reads this + instead of hardcoding per-tool MCP behavior in a name table (REQUIREMENT-039). **Absent = + undeclared**: the kernel applies its historical default (provider plugins are MCP-capable); + only an explicit `false` opts a provider out. This is the proof-of-pattern field for a + growing family of declared kernel-behavior capabilities (launch template, permission-mode + flag, reasoning-effort, default model, ...) migrating off the kernel's hardcoded name tables. + ### 8.3 `RpcRequest` / `RpcNotification` / `RpcResponse` / `RpcError` See §2.