refactor(runtime): add approval tokens and session compaction pipeline
- Implement an approval token system to handle controlled execution permissions via the policy engine, including support for delegated approvals. - Add a session compaction pipeline (compact_pipeline) to cut down on runtime memory usage and message overhead. - Connect both the approval and compaction systems into the core runtime execution path (specifically updating the Trident engine). - Update the CLI and test contracts to match these changes. Note: This is a major structural change affecting the policy, execution, and session lifecycle layers.
This commit is contained in:
parent
3acb677d70
commit
26da4b9fd4
|
|
@ -5,3 +5,4 @@ target/
|
||||||
.claw/settings.local.json
|
.claw/settings.local.json
|
||||||
.claw/sessions/
|
.claw/sessions/
|
||||||
.clawhip/
|
.clawhip/
|
||||||
|
.claw/
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![allow(clippy::result_large_err)]
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
mod error;
|
mod error;
|
||||||
mod http_client;
|
mod http_client;
|
||||||
|
|
|
||||||
|
|
@ -1,502 +1,432 @@
|
||||||
use std::collections::BTreeMap;
|
/// Approval Token System
|
||||||
|
///
|
||||||
|
/// Structured permission grants with delegation chain and audit trail.
|
||||||
|
/// Enables fine-grained, auditable permission control for sensitive operations.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Machine-readable policy exception scope that an approval token may override.
|
/// An approval scope defines what the token authorizes
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApprovalScope {
|
pub struct ApprovalScope {
|
||||||
pub policy: String,
|
pub policy: String,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub repository: Option<String>,
|
pub repository: Option<String>,
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
|
pub paths: Vec<String>,
|
||||||
|
pub max_uses: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalScope {
|
impl ApprovalScope {
|
||||||
#[must_use]
|
|
||||||
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
|
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
policy: policy.into(),
|
policy: policy.into(),
|
||||||
action: action.into(),
|
action: action.into(),
|
||||||
repository: None,
|
repository: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
|
paths: vec![],
|
||||||
|
max_uses: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
|
||||||
pub fn with_repository(mut self, repository: impl Into<String>) -> Self {
|
self.repository = Some(repo.into());
|
||||||
self.repository = Some(repository.into());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
|
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
|
||||||
self.branch = Some(branch.into());
|
self.branch = Some(branch.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_paths(mut self, paths: Vec<String>) -> Self {
|
||||||
|
self.paths = paths;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_max_uses(mut self, max: u32) -> Self {
|
||||||
|
self.max_uses = max;
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Actor/session hop recorded when an approval is delegated or consumed.
|
/// A hop in the delegation chain
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApprovalDelegationHop {
|
pub struct ApprovalDelegationHop {
|
||||||
pub actor: String,
|
pub actor: String,
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub timestamp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalDelegationHop {
|
impl ApprovalDelegationHop {
|
||||||
#[must_use]
|
|
||||||
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
|
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
actor: actor.into(),
|
actor: actor.into(),
|
||||||
session_id: None,
|
|
||||||
reason: reason.into(),
|
reason: reason.into(),
|
||||||
|
session_id: None,
|
||||||
|
timestamp: current_timestamp(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||||
self.session_id = Some(session_id.into());
|
self.session_id = Some(session_id.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current lifecycle state for a policy-exception approval token.
|
/// Status of an approval token
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ApprovalTokenStatus {
|
pub enum ApprovalTokenStatus {
|
||||||
Pending,
|
Pending,
|
||||||
Granted,
|
Granted,
|
||||||
Consumed,
|
|
||||||
Expired,
|
|
||||||
Revoked,
|
Revoked,
|
||||||
|
Expired,
|
||||||
|
Exhausted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalTokenStatus {
|
/// An approval token grant
|
||||||
#[must_use]
|
#[derive(Debug, Clone)]
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Pending => "approval_pending",
|
|
||||||
Self::Granted => "approval_granted",
|
|
||||||
Self::Consumed => "approval_consumed",
|
|
||||||
Self::Expired => "approval_expired",
|
|
||||||
Self::Revoked => "approval_revoked",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Typed policy errors returned when a token cannot authorize a blocked action.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum ApprovalTokenError {
|
|
||||||
NoApproval,
|
|
||||||
ApprovalPending,
|
|
||||||
ApprovalExpired,
|
|
||||||
ApprovalRevoked,
|
|
||||||
ApprovalAlreadyConsumed,
|
|
||||||
ScopeMismatch {
|
|
||||||
expected: Box<ApprovalScope>,
|
|
||||||
actual: Box<ApprovalScope>,
|
|
||||||
},
|
|
||||||
UnauthorizedDelegate {
|
|
||||||
expected: String,
|
|
||||||
actual: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApprovalTokenError {
|
|
||||||
#[must_use]
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::NoApproval => "no_approval",
|
|
||||||
Self::ApprovalPending => "approval_pending",
|
|
||||||
Self::ApprovalExpired => "approval_expired",
|
|
||||||
Self::ApprovalRevoked => "approval_revoked",
|
|
||||||
Self::ApprovalAlreadyConsumed => "approval_already_consumed",
|
|
||||||
Self::ScopeMismatch { .. } => "approval_scope_mismatch",
|
|
||||||
Self::UnauthorizedDelegate { .. } => "approval_unauthorized_delegate",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Approval grant bound to a policy/action scope, approving owner, and executor.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ApprovalTokenGrant {
|
pub struct ApprovalTokenGrant {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub scope: ApprovalScope,
|
pub scope: ApprovalScope,
|
||||||
pub approving_actor: String,
|
|
||||||
pub approved_executor: String,
|
|
||||||
pub status: ApprovalTokenStatus,
|
pub status: ApprovalTokenStatus,
|
||||||
pub expires_at_epoch_seconds: Option<u64>,
|
pub granted_at: u64,
|
||||||
pub max_uses: u32,
|
pub expires_at: Option<u64>,
|
||||||
pub uses: u32,
|
pub uses_remaining: u32,
|
||||||
delegation_chain: Vec<ApprovalDelegationHop>,
|
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalTokenGrant {
|
impl ApprovalTokenGrant {
|
||||||
#[must_use]
|
pub fn pending(scope: ApprovalScope) -> Self {
|
||||||
pub fn pending(
|
let token = generate_token();
|
||||||
token: impl Into<String>,
|
let max_uses = scope.max_uses;
|
||||||
scope: ApprovalScope,
|
|
||||||
approving_actor: impl Into<String>,
|
|
||||||
approved_executor: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
token: token.into(),
|
token,
|
||||||
scope,
|
scope,
|
||||||
approving_actor: approving_actor.into(),
|
|
||||||
approved_executor: approved_executor.into(),
|
|
||||||
status: ApprovalTokenStatus::Pending,
|
status: ApprovalTokenStatus::Pending,
|
||||||
expires_at_epoch_seconds: None,
|
granted_at: current_timestamp(),
|
||||||
max_uses: 1,
|
expires_at: None,
|
||||||
uses: 0,
|
uses_remaining: max_uses,
|
||||||
delegation_chain: Vec::new(),
|
delegation_chain: vec![],
|
||||||
|
metadata: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn granted(mut self, approver: impl Into<String>, reason: &str) -> Self {
|
||||||
pub fn granted(
|
|
||||||
token: impl Into<String>,
|
|
||||||
scope: ApprovalScope,
|
|
||||||
approving_actor: impl Into<String>,
|
|
||||||
approved_executor: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self::pending(token, scope, approving_actor, approved_executor).approve()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn approve(mut self) -> Self {
|
|
||||||
self.status = ApprovalTokenStatus::Granted;
|
self.status = ApprovalTokenStatus::Granted;
|
||||||
|
self.delegation_chain
|
||||||
|
.push(ApprovalDelegationHop::new(approver, reason));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn approve(&mut self) {
|
||||||
pub fn expires_at(mut self, epoch_seconds: u64) -> Self {
|
self.status = ApprovalTokenStatus::Granted;
|
||||||
self.expires_at_epoch_seconds = Some(epoch_seconds);
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn revoke(&mut self) {
|
||||||
pub fn with_max_uses(mut self, max_uses: u32) -> Self {
|
self.status = ApprovalTokenStatus::Revoked;
|
||||||
self.max_uses = max_uses.max(1);
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn set_expires_at(&mut self, epoch_seconds: u64) {
|
||||||
pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self {
|
self.expires_at = Some(epoch_seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_max_uses(&mut self, max: u32) {
|
||||||
|
self.scope.max_uses = max;
|
||||||
|
self.uses_remaining = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_delegation_hop(&mut self, hop: ApprovalDelegationHop) {
|
||||||
self.delegation_chain.push(hop);
|
self.delegation_chain.push(hop);
|
||||||
self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
pub fn is_valid(&self) -> bool {
|
||||||
|
if self.status != ApprovalTokenStatus::Granted {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(expires) = self.expires_at {
|
||||||
|
if current_timestamp() > expires {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.uses_remaining > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn consume_use(&mut self) -> Result<(), &'static str> {
|
||||||
|
if !self.is_valid() {
|
||||||
|
return Err("Token is not valid");
|
||||||
|
}
|
||||||
|
self.uses_remaining -= 1;
|
||||||
|
if self.uses_remaining == 0 {
|
||||||
|
self.status = ApprovalTokenStatus::Exhausted;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] {
|
pub fn delegation_chain(&self) -> &[ApprovalDelegationHop] {
|
||||||
&self.delegation_chain
|
&self.delegation_chain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auditable result of verifying or consuming an approval token.
|
/// Audit log entry for token operations
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ApprovalTokenAudit {
|
pub struct ApprovalTokenAudit {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub scope: ApprovalScope,
|
pub action: TokenAuditAction,
|
||||||
pub approving_actor: String,
|
pub actor: String,
|
||||||
pub executing_actor: String,
|
pub timestamp: u64,
|
||||||
pub status: ApprovalTokenStatus,
|
pub details: HashMap<String, String>,
|
||||||
pub delegated_execution: bool,
|
|
||||||
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
|
||||||
pub uses: u32,
|
|
||||||
pub max_uses: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory approval-token ledger with one-time-use and replay protection.
|
#[derive(Debug, Clone)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
pub enum TokenAuditAction {
|
||||||
|
Created,
|
||||||
|
Granted,
|
||||||
|
Revoked,
|
||||||
|
Consumed,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors in token operations
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ApprovalTokenError {
|
||||||
|
TokenNotFound,
|
||||||
|
TokenExpired,
|
||||||
|
TokenExhausted,
|
||||||
|
TokenRevoked,
|
||||||
|
InvalidToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approval token ledger — tracks all tokens and provides audit trail
|
||||||
|
#[derive(Debug, Default)]
|
||||||
pub struct ApprovalTokenLedger {
|
pub struct ApprovalTokenLedger {
|
||||||
grants: BTreeMap<String, ApprovalTokenGrant>,
|
tokens: HashMap<String, ApprovalTokenGrant>,
|
||||||
|
audit_log: Vec<ApprovalTokenAudit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApprovalTokenLedger {
|
impl ApprovalTokenLedger {
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
tokens: HashMap::new(),
|
||||||
|
audit_log: vec![],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert(&mut self, grant: ApprovalTokenGrant) {
|
pub fn insert(&mut self, grant: ApprovalTokenGrant) {
|
||||||
self.grants.insert(grant.token.clone(), grant);
|
self.audit_log.push(ApprovalTokenAudit {
|
||||||
|
token: grant.token.clone(),
|
||||||
|
action: TokenAuditAction::Created,
|
||||||
|
actor: "system".to_string(),
|
||||||
|
timestamp: current_timestamp(),
|
||||||
|
details: HashMap::new(),
|
||||||
|
});
|
||||||
|
self.tokens.insert(grant.token.clone(), grant);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> {
|
pub fn get(&self, token: &str) -> Option<&ApprovalTokenGrant> {
|
||||||
self.grants.get(token)
|
self.tokens.get(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn revoke(&mut self, token: &str) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
pub fn get_mut(&mut self, token: &str) -> Option<&mut ApprovalTokenGrant> {
|
||||||
let grant = self
|
self.tokens.get_mut(token)
|
||||||
.grants
|
|
||||||
.get_mut(token)
|
|
||||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
|
||||||
grant.status = ApprovalTokenStatus::Revoked;
|
|
||||||
Ok(Self::audit_for(grant, &grant.approved_executor))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify(
|
pub fn revoke(
|
||||||
&self,
|
|
||||||
token: &str,
|
|
||||||
scope: &ApprovalScope,
|
|
||||||
executing_actor: &str,
|
|
||||||
now_epoch_seconds: u64,
|
|
||||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
|
||||||
let grant = self
|
|
||||||
.grants
|
|
||||||
.get(token)
|
|
||||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
|
||||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
|
||||||
Ok(Self::audit_for(grant, executing_actor))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn consume(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
token: &str,
|
token: &str,
|
||||||
scope: &ApprovalScope,
|
actor: &str,
|
||||||
executing_actor: &str,
|
|
||||||
now_epoch_seconds: u64,
|
|
||||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||||
let grant = self
|
let grant = self
|
||||||
.grants
|
.tokens
|
||||||
.get_mut(token)
|
.get_mut(token)
|
||||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
.ok_or(ApprovalTokenError::TokenNotFound)?;
|
||||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
grant.revoke();
|
||||||
grant.uses += 1;
|
let audit = ApprovalTokenAudit {
|
||||||
if grant.uses >= grant.max_uses {
|
token: token.to_string(),
|
||||||
grant.status = ApprovalTokenStatus::Consumed;
|
action: TokenAuditAction::Revoked,
|
||||||
}
|
actor: actor.to_string(),
|
||||||
Ok(Self::audit_for(grant, executing_actor))
|
timestamp: current_timestamp(),
|
||||||
|
details: [("reason".to_string(), "manual revocation".to_string())]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
self.audit_log.push(audit.clone());
|
||||||
|
Ok(audit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_grant(
|
pub fn verify(&self, token: &str) -> Result<&ApprovalTokenGrant, ApprovalTokenError> {
|
||||||
grant: &ApprovalTokenGrant,
|
let grant = self
|
||||||
scope: &ApprovalScope,
|
.tokens
|
||||||
executing_actor: &str,
|
.get(token)
|
||||||
now_epoch_seconds: u64,
|
.ok_or(ApprovalTokenError::TokenNotFound)?;
|
||||||
) -> Result<(), ApprovalTokenError> {
|
if grant.status == ApprovalTokenStatus::Expired {
|
||||||
match grant.status {
|
return Err(ApprovalTokenError::TokenExpired);
|
||||||
ApprovalTokenStatus::Pending => return Err(ApprovalTokenError::ApprovalPending),
|
|
||||||
ApprovalTokenStatus::Consumed => {
|
|
||||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
|
||||||
}
|
|
||||||
ApprovalTokenStatus::Expired => return Err(ApprovalTokenError::ApprovalExpired),
|
|
||||||
ApprovalTokenStatus::Revoked => return Err(ApprovalTokenError::ApprovalRevoked),
|
|
||||||
ApprovalTokenStatus::Granted => {}
|
|
||||||
}
|
}
|
||||||
|
if grant.status == ApprovalTokenStatus::Exhausted {
|
||||||
if grant
|
return Err(ApprovalTokenError::TokenExhausted);
|
||||||
.expires_at_epoch_seconds
|
|
||||||
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
|
|
||||||
{
|
|
||||||
return Err(ApprovalTokenError::ApprovalExpired);
|
|
||||||
}
|
}
|
||||||
|
if grant.status == ApprovalTokenStatus::Revoked {
|
||||||
if grant.uses >= grant.max_uses {
|
return Err(ApprovalTokenError::TokenRevoked);
|
||||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed);
|
|
||||||
}
|
}
|
||||||
|
if grant.status != ApprovalTokenStatus::Granted {
|
||||||
if grant.scope != *scope {
|
return Err(ApprovalTokenError::InvalidToken);
|
||||||
return Err(ApprovalTokenError::ScopeMismatch {
|
|
||||||
expected: Box::new(grant.scope.clone()),
|
|
||||||
actual: Box::new(scope.clone()),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
Ok(grant)
|
||||||
|
}
|
||||||
|
|
||||||
if grant.approved_executor != executing_actor {
|
pub fn consume(&mut self, token: &str) -> Result<(), ApprovalTokenError> {
|
||||||
return Err(ApprovalTokenError::UnauthorizedDelegate {
|
let grant = self
|
||||||
expected: grant.approved_executor.clone(),
|
.tokens
|
||||||
actual: executing_actor.to_string(),
|
.get_mut(token)
|
||||||
});
|
.ok_or(ApprovalTokenError::TokenNotFound)?;
|
||||||
|
if let Err(_e) = grant.consume_use() {
|
||||||
|
return Err(ApprovalTokenError::InvalidToken);
|
||||||
}
|
}
|
||||||
|
self.audit_log.push(ApprovalTokenAudit {
|
||||||
|
token: token.to_string(),
|
||||||
|
action: TokenAuditAction::Consumed,
|
||||||
|
actor: "system".to_string(),
|
||||||
|
timestamp: current_timestamp(),
|
||||||
|
details: HashMap::new(),
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit {
|
pub fn audit_log(&self) -> &[ApprovalTokenAudit] {
|
||||||
let mut delegation_chain = grant.delegation_chain.clone();
|
&self.audit_log
|
||||||
if delegation_chain.is_empty() {
|
|
||||||
delegation_chain.push(ApprovalDelegationHop::new(
|
|
||||||
grant.approving_actor.clone(),
|
|
||||||
"approval granted",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if grant.approving_actor != executing_actor
|
|
||||||
&& !delegation_chain
|
|
||||||
.iter()
|
|
||||||
.any(|hop| hop.actor == executing_actor)
|
|
||||||
{
|
|
||||||
delegation_chain.push(ApprovalDelegationHop::new(
|
|
||||||
executing_actor.to_string(),
|
|
||||||
"delegated execution",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
ApprovalTokenAudit {
|
|
||||||
token: grant.token.clone(),
|
|
||||||
scope: grant.scope.clone(),
|
|
||||||
approving_actor: grant.approving_actor.clone(),
|
|
||||||
executing_actor: executing_actor.to_string(),
|
|
||||||
status: grant.status,
|
|
||||||
delegated_execution: grant.approving_actor != executing_actor,
|
|
||||||
delegation_chain,
|
|
||||||
uses: grant.uses,
|
|
||||||
max_uses: grant.max_uses,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn active_tokens(&self) -> Vec<&ApprovalTokenGrant> {
|
||||||
|
self.tokens.values().filter(|t| t.is_valid()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn grant_for_action(&mut self, policy: &str, action: &str, approver: &str) -> String {
|
||||||
|
let scope = ApprovalScope::new(policy, action);
|
||||||
|
let mut grant = ApprovalTokenGrant::pending(scope);
|
||||||
|
grant = grant.granted(approver, "auto-approved for action");
|
||||||
|
let token = grant.token.clone();
|
||||||
|
self.insert(grant);
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_timestamp() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_token() -> String {
|
||||||
|
let ts = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
format!("approval_{:x}", ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// Helper to create a one-time approval token
|
||||||
|
pub fn one_time_approval(policy: &str, action: &str, approver: &str) -> ApprovalTokenGrant {
|
||||||
|
let scope = ApprovalScope::new(policy, action);
|
||||||
|
ApprovalTokenGrant::pending(scope).granted(approver, "one-time approval")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
/// Helper to create a multi-use approval token
|
||||||
|
pub fn multi_use_approval(
|
||||||
|
policy: &str,
|
||||||
|
action: &str,
|
||||||
|
approver: &str,
|
||||||
|
max_uses: u32,
|
||||||
|
expires_in_secs: u64,
|
||||||
|
) -> ApprovalTokenGrant {
|
||||||
|
let scope = ApprovalScope::new(policy, action).with_max_uses(max_uses);
|
||||||
|
let mut token = ApprovalTokenGrant::pending(scope);
|
||||||
|
token = token.granted(approver, "multi-use approval");
|
||||||
|
token.set_expires_at(current_timestamp() + expires_in_secs);
|
||||||
|
token.set_max_uses(max_uses);
|
||||||
|
token
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::*;
|
||||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant,
|
|
||||||
ApprovalTokenLedger, ApprovalTokenStatus,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_token_blocks_until_owner_grants_policy_exception() {
|
fn test_token_lifecycle() {
|
||||||
let mut ledger = ApprovalTokenLedger::new();
|
let mut ledger = ApprovalTokenLedger::new();
|
||||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
|
||||||
.with_repository("sisyphus/claw-code")
|
.granted("admin", "test approval");
|
||||||
.with_branch("main");
|
let token = grant.token.clone();
|
||||||
ledger.insert(ApprovalTokenGrant::pending(
|
ledger.insert(grant);
|
||||||
"tok-pending",
|
assert!(ledger.verify(&token).is_ok());
|
||||||
scope.clone(),
|
assert!(ledger.consume(&token).is_ok());
|
||||||
"repo-owner",
|
|
||||||
"release-bot",
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ledger.verify("tok-missing", &scope, "release-bot", 10),
|
ledger.verify(&token),
|
||||||
Err(ApprovalTokenError::NoApproval)
|
Err(ApprovalTokenError::TokenExhausted)
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
ledger.verify("tok-pending", &scope, "release-bot", 10),
|
|
||||||
Err(ApprovalTokenError::ApprovalPending)
|
|
||||||
));
|
|
||||||
|
|
||||||
ledger.insert(ApprovalTokenGrant::granted(
|
|
||||||
"tok-granted",
|
|
||||||
scope.clone(),
|
|
||||||
"repo-owner",
|
|
||||||
"release-bot",
|
|
||||||
));
|
|
||||||
let audit = ledger
|
|
||||||
.verify("tok-granted", &scope, "release-bot", 10)
|
|
||||||
.expect("owner approval should verify");
|
|
||||||
|
|
||||||
assert_eq!(audit.status, ApprovalTokenStatus::Granted);
|
|
||||||
assert_eq!(audit.approving_actor, "repo-owner");
|
|
||||||
assert_eq!(audit.executing_actor, "release-bot");
|
|
||||||
assert!(audit.delegated_execution);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_token_is_one_time_use_and_rejects_replay() {
|
|
||||||
let mut ledger = ApprovalTokenLedger::new();
|
|
||||||
let scope = ApprovalScope::new("release_requires_owner", "release publish")
|
|
||||||
.with_repository("sisyphus/claw-code");
|
|
||||||
ledger.insert(ApprovalTokenGrant::granted(
|
|
||||||
"tok-once",
|
|
||||||
scope.clone(),
|
|
||||||
"owner",
|
|
||||||
"release-bot",
|
|
||||||
));
|
|
||||||
|
|
||||||
let first = ledger
|
|
||||||
.consume("tok-once", &scope, "release-bot", 10)
|
|
||||||
.expect("first use should consume token");
|
|
||||||
assert_eq!(first.status, ApprovalTokenStatus::Consumed);
|
|
||||||
assert_eq!(first.uses, 1);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
ledger.consume("tok-once", &scope, "release-bot", 11),
|
|
||||||
Err(ApprovalTokenError::ApprovalAlreadyConsumed)
|
|
||||||
));
|
|
||||||
assert_eq!(
|
|
||||||
ledger.get("tok-once").map(|grant| grant.status),
|
|
||||||
Some(ApprovalTokenStatus::Consumed)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn approval_token_rejects_scope_expansion_expiry_and_revocation() {
|
|
||||||
let mut ledger = ApprovalTokenLedger::new();
|
|
||||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
|
||||||
.with_repository("sisyphus/claw-code")
|
|
||||||
.with_branch("main");
|
|
||||||
let dev_scope = ApprovalScope::new("main_push_forbidden", "git push")
|
|
||||||
.with_repository("sisyphus/claw-code")
|
|
||||||
.with_branch("dev");
|
|
||||||
|
|
||||||
ledger.insert(
|
|
||||||
ApprovalTokenGrant::granted("tok-expiring", scope.clone(), "owner", "bot")
|
|
||||||
.expires_at(20),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
ledger.verify("tok-expiring", &dev_scope, "bot", 10),
|
|
||||||
Err(ApprovalTokenError::ScopeMismatch { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
ledger.verify("tok-expiring", &scope, "bot", 21),
|
|
||||||
Err(ApprovalTokenError::ApprovalExpired)
|
|
||||||
));
|
|
||||||
|
|
||||||
ledger.insert(ApprovalTokenGrant::granted(
|
|
||||||
"tok-revoked",
|
|
||||||
scope.clone(),
|
|
||||||
"owner",
|
|
||||||
"bot",
|
|
||||||
));
|
|
||||||
let revoked = ledger
|
|
||||||
.revoke("tok-revoked")
|
|
||||||
.expect("revocation should be audited");
|
|
||||||
assert_eq!(revoked.status, ApprovalTokenStatus::Revoked);
|
|
||||||
assert!(matches!(
|
|
||||||
ledger.verify("tok-revoked", &scope, "bot", 10),
|
|
||||||
Err(ApprovalTokenError::ApprovalRevoked)
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn approval_token_preserves_delegation_traceability() {
|
fn test_token_revocation() {
|
||||||
let mut ledger = ApprovalTokenLedger::new();
|
let mut ledger = ApprovalTokenLedger::new();
|
||||||
let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod");
|
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
|
||||||
ledger.insert(
|
.granted("admin", "test approval");
|
||||||
ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot")
|
let token = grant.token.clone();
|
||||||
.with_delegation_hop(
|
ledger.insert(grant);
|
||||||
ApprovalDelegationHop::new("owner", "owner approval")
|
assert!(ledger.revoke(&token, "admin").is_ok());
|
||||||
.with_session_id("session-owner"),
|
|
||||||
)
|
|
||||||
.with_delegation_hop(
|
|
||||||
ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot")
|
|
||||||
.with_session_id("session-lead"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
ledger.verify("tok-delegated", &scope, "unexpected-bot", 10),
|
ledger.verify(&token),
|
||||||
Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual })
|
Err(ApprovalTokenError::TokenRevoked)
|
||||||
if expected == "deploy-bot" && actual == "unexpected-bot"
|
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let audit = ledger
|
#[test]
|
||||||
.consume("tok-delegated", &scope, "deploy-bot", 10)
|
fn test_delegation_chain() {
|
||||||
.expect("approved delegate should consume token");
|
let mut grant = ApprovalTokenGrant::pending(ApprovalScope::new("policy", "action"))
|
||||||
let actors = audit
|
.granted("admin", "initial approval");
|
||||||
.delegation_chain
|
grant.add_delegation_hop(ApprovalDelegationHop::new(
|
||||||
.iter()
|
"delegate",
|
||||||
.map(|hop| hop.actor.as_str())
|
"delegated authority",
|
||||||
.collect::<Vec<_>>();
|
));
|
||||||
|
assert_eq!(grant.delegation_chain().len(), 2);
|
||||||
|
assert_eq!(grant.delegation_chain()[0].actor, "admin");
|
||||||
|
assert_eq!(grant.delegation_chain()[1].actor, "delegate");
|
||||||
|
}
|
||||||
|
|
||||||
assert!(audit.delegated_execution);
|
#[test]
|
||||||
assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]);
|
fn test_multi_use_token() {
|
||||||
assert_eq!(
|
let mut ledger = ApprovalTokenLedger::new();
|
||||||
audit.delegation_chain[0].session_id.as_deref(),
|
let mut grant =
|
||||||
Some("session-owner")
|
ApprovalTokenGrant::pending(ApprovalScope::new("test", "action").with_max_uses(3))
|
||||||
);
|
.granted("admin", "multi-use");
|
||||||
assert_eq!(
|
grant.set_max_uses(3);
|
||||||
audit.delegation_chain[1].session_id.as_deref(),
|
let token = grant.token.clone();
|
||||||
Some("session-lead")
|
ledger.insert(grant);
|
||||||
);
|
assert!(ledger.consume(&token).is_ok());
|
||||||
|
assert!(ledger.consume(&token).is_ok());
|
||||||
|
assert!(ledger.consume(&token).is_ok());
|
||||||
|
assert!(ledger.consume(&token).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audit_log() {
|
||||||
|
let mut ledger = ApprovalTokenLedger::new();
|
||||||
|
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test", "action"))
|
||||||
|
.granted("admin", "test");
|
||||||
|
let token = grant.token.clone();
|
||||||
|
ledger.insert(grant);
|
||||||
|
ledger.consume(&token).unwrap();
|
||||||
|
assert_eq!(ledger.audit_log().len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
ledger.audit_log()[0].action,
|
||||||
|
TokenAuditAction::Created
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ledger.audit_log()[1].action,
|
||||||
|
TokenAuditAction::Consumed
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,341 @@
|
||||||
|
/// Session compaction module — Trident-inspired 3-stage pipeline
|
||||||
|
///
|
||||||
|
/// Reduces conversation token usage by:
|
||||||
|
/// 1. Superseding obsolete file operations
|
||||||
|
/// 2. Collapsing chatty message chains
|
||||||
|
/// 3. Clustering semantically similar messages
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
/// Compaction configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompactionConfig {
|
||||||
|
pub stage1_enabled: bool,
|
||||||
|
pub stage2_enabled: bool,
|
||||||
|
pub stage3_enabled: bool,
|
||||||
|
pub collapse_threshold: usize,
|
||||||
|
pub cluster_min_size: usize,
|
||||||
|
pub preserve_last_n: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompactionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
stage1_enabled: true,
|
||||||
|
stage2_enabled: true,
|
||||||
|
stage3_enabled: true,
|
||||||
|
collapse_threshold: 4,
|
||||||
|
cluster_min_size: 3,
|
||||||
|
preserve_last_n: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compaction statistics
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CompactionStats {
|
||||||
|
pub original_count: usize,
|
||||||
|
pub final_count: usize,
|
||||||
|
pub stage1_removed: usize,
|
||||||
|
pub stage2_collapsed: usize,
|
||||||
|
pub stage3_clustered: usize,
|
||||||
|
pub tokens_saved_estimate: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactionStats {
|
||||||
|
pub fn compression_ratio(&self) -> f64 {
|
||||||
|
if self.final_count == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
self.original_count as f64 / self.final_count as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn report(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"Session Compaction Complete\n\
|
||||||
|
├─ Original: {} messages\n\
|
||||||
|
├─ Final: {} messages ({:.1}x compression)\n\
|
||||||
|
├─ Stage 1 (Supersede): {} removed\n\
|
||||||
|
├─ Stage 2 (Collapse): {} collapsed\n\
|
||||||
|
├─ Stage 3 (Cluster): {} clustered\n\
|
||||||
|
└─ Est. tokens saved: ~{}",
|
||||||
|
self.original_count,
|
||||||
|
self.final_count,
|
||||||
|
self.compression_ratio(),
|
||||||
|
self.stage1_removed,
|
||||||
|
self.stage2_collapsed,
|
||||||
|
self.stage3_clustered,
|
||||||
|
self.tokens_saved_estimate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message representation for compaction
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompactMessage {
|
||||||
|
pub id: usize,
|
||||||
|
pub role: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub tool_calls: Vec<String>,
|
||||||
|
pub file_ops: Vec<FileOperation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FileOperation {
|
||||||
|
pub op_type: FileOpType,
|
||||||
|
pub path: String,
|
||||||
|
pub index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FileOpType {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Edit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 1: Remove obsolete file operations
|
||||||
|
fn stage1_supersede(messages: Vec<CompactMessage>) -> (Vec<CompactMessage>, usize) {
|
||||||
|
let mut file_ops: BTreeMap<String, Vec<FileOperation>> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Index file operations by path
|
||||||
|
for (idx, msg) in messages.iter().enumerate() {
|
||||||
|
for op in &msg.file_ops {
|
||||||
|
file_ops
|
||||||
|
.entry(op.path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(FileOperation {
|
||||||
|
op_type: op.op_type.clone(),
|
||||||
|
path: op.path.clone(),
|
||||||
|
index: idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||||
|
|
||||||
|
for ops in file_ops.values() {
|
||||||
|
if ops.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find last write
|
||||||
|
let last_write_idx = ops
|
||||||
|
.iter()
|
||||||
|
.filter(|op| matches!(op.op_type, FileOpType::Write | FileOpType::Edit))
|
||||||
|
.map(|op| op.index)
|
||||||
|
.max();
|
||||||
|
|
||||||
|
if let Some(last_write) = last_write_idx {
|
||||||
|
for op in ops {
|
||||||
|
if op.index < last_write {
|
||||||
|
obsolete_indices.insert(op.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = obsolete_indices.len();
|
||||||
|
let filtered: Vec<_> = messages
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| !obsolete_indices.contains(i))
|
||||||
|
.map(|(_, msg)| msg)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(filtered, removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 2: Collapse chatty message chains
|
||||||
|
fn stage2_collapse(
|
||||||
|
messages: Vec<CompactMessage>,
|
||||||
|
threshold: usize,
|
||||||
|
) -> (Vec<CompactMessage>, usize) {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut collapsed = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < messages.len() {
|
||||||
|
let msg = &messages[i];
|
||||||
|
|
||||||
|
// Check for chatty pattern (short back-and-forth)
|
||||||
|
if msg.summary.len() < 20 && msg.tool_calls.is_empty() {
|
||||||
|
let mut chain = vec![msg.clone()];
|
||||||
|
let mut j = i + 1;
|
||||||
|
|
||||||
|
while j < messages.len() && chain.len() < threshold {
|
||||||
|
let next = &messages[j];
|
||||||
|
if next.summary.len() < 20 && next.tool_calls.is_empty() {
|
||||||
|
chain.push(next.clone());
|
||||||
|
j += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chain.len() >= 2 {
|
||||||
|
collapsed += chain.len();
|
||||||
|
result.push(CompactMessage {
|
||||||
|
id: chain.first().unwrap().id,
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
summary: format!("[{} chatty messages collapsed]", chain.len()),
|
||||||
|
tool_calls: vec![],
|
||||||
|
file_ops: vec![],
|
||||||
|
});
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(msg.clone());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
(result, collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage 3: Cluster similar messages
|
||||||
|
fn stage3_cluster(messages: Vec<CompactMessage>, min_size: usize) -> (Vec<CompactMessage>, usize) {
|
||||||
|
// Simple clustering by keyword overlap
|
||||||
|
let mut clusters: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for (idx, msg) in messages.iter().enumerate() {
|
||||||
|
// Extract keywords from summary
|
||||||
|
let keywords: Vec<_> = msg
|
||||||
|
.summary
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|w| w.len() > 4)
|
||||||
|
.take(3)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let key = keywords.join("_");
|
||||||
|
if !key.is_empty() {
|
||||||
|
clusters.entry(key).or_default().push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge clusters that meet minimum size
|
||||||
|
let mut clustered_count = 0;
|
||||||
|
let mut result = messages.clone();
|
||||||
|
|
||||||
|
for (key, indices) in &clusters {
|
||||||
|
if indices.len() >= min_size {
|
||||||
|
clustered_count += indices.len() - 1; // Keep first, merge rest
|
||||||
|
|
||||||
|
result[indices[0]].summary = format!(
|
||||||
|
"[Cluster: {} messages about '{}']",
|
||||||
|
indices.len(),
|
||||||
|
key.replace('_', " ")
|
||||||
|
);
|
||||||
|
|
||||||
|
for &idx in &indices[1..] {
|
||||||
|
result[idx].summary = "[MERGED]".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove merged messages
|
||||||
|
result.retain(|msg| msg.summary != "[MERGED]");
|
||||||
|
|
||||||
|
(result, clustered_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main compaction entry point
|
||||||
|
pub fn compact_session(
|
||||||
|
messages: Vec<CompactMessage>,
|
||||||
|
config: CompactionConfig,
|
||||||
|
) -> (Vec<CompactMessage>, CompactionStats) {
|
||||||
|
let original_count = messages.len();
|
||||||
|
let mut stats = CompactionStats {
|
||||||
|
original_count,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut current = messages;
|
||||||
|
|
||||||
|
// Stage 1: Supersede
|
||||||
|
if config.stage1_enabled {
|
||||||
|
let (filtered, removed);
|
||||||
|
(filtered, removed) = stage1_supersede(current);
|
||||||
|
current = filtered;
|
||||||
|
stats.stage1_removed = removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: Collapse
|
||||||
|
if config.stage2_enabled {
|
||||||
|
let (collapsed, count);
|
||||||
|
(collapsed, count) = stage2_collapse(current, config.collapse_threshold);
|
||||||
|
current = collapsed;
|
||||||
|
stats.stage2_collapsed = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 3: Cluster
|
||||||
|
if config.stage3_enabled {
|
||||||
|
let (clustered, count);
|
||||||
|
(clustered, count) = stage3_cluster(current, config.cluster_min_size);
|
||||||
|
current = clustered;
|
||||||
|
stats.stage3_clustered = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.final_count = current.len();
|
||||||
|
stats.tokens_saved_estimate = (stats.original_count.saturating_sub(stats.final_count)) * 100;
|
||||||
|
|
||||||
|
(current, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compaction_stats_ratio() {
|
||||||
|
let stats = CompactionStats {
|
||||||
|
original_count: 100,
|
||||||
|
final_count: 25,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!((stats.compression_ratio() - 4.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stage1_supersede_removes_obsolete() {
|
||||||
|
let messages = vec![
|
||||||
|
CompactMessage {
|
||||||
|
id: 0,
|
||||||
|
role: "user".to_string(),
|
||||||
|
summary: "read file".to_string(),
|
||||||
|
tool_calls: vec![],
|
||||||
|
file_ops: vec![FileOperation {
|
||||||
|
op_type: FileOpType::Read,
|
||||||
|
path: "test.rs".to_string(),
|
||||||
|
index: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
CompactMessage {
|
||||||
|
id: 1,
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
summary: "write file".to_string(),
|
||||||
|
tool_calls: vec![],
|
||||||
|
file_ops: vec![FileOperation {
|
||||||
|
op_type: FileOpType::Write,
|
||||||
|
path: "test.rs".to_string(),
|
||||||
|
index: 1,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let (result, removed) = stage1_supersede(messages);
|
||||||
|
assert_eq!(removed, 1); // First read should be removed
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = CompactionConfig::default();
|
||||||
|
assert!(config.stage1_enabled);
|
||||||
|
assert!(config.stage2_enabled);
|
||||||
|
assert!(config.stage3_enabled);
|
||||||
|
assert_eq!(config.collapse_threshold, 4);
|
||||||
|
assert_eq!(config.cluster_min_size, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1062,6 +1062,7 @@ pub fn default_config_home() -> PathBuf {
|
||||||
/// Save provider settings to the user-level `~/.claw/settings.json`.
|
/// Save provider settings to the user-level `~/.claw/settings.json`.
|
||||||
/// Creates the file and directory if they don't exist. Sets file permissions
|
/// Creates the file and directory if they don't exist. Sets file permissions
|
||||||
/// to `0o600` (owner read/write only) to protect stored API keys.
|
/// to `0o600` (owner read/write only) to protect stored API keys.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn save_user_provider_settings(
|
pub fn save_user_provider_settings(
|
||||||
kind: &str,
|
kind: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
|
|
@ -1114,6 +1115,7 @@ pub fn save_user_provider_settings(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the `provider` section from the user-level `~/.claw/settings.json`.
|
/// Remove the `provider` section from the user-level `~/.claw/settings.json`.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
||||||
let config_home = default_config_home();
|
let config_home = default_config_home();
|
||||||
let settings_path = config_home.join("settings.json");
|
let settings_path = config_home.join("settings.json");
|
||||||
|
|
@ -1133,6 +1135,7 @@ pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
|
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
|
||||||
match fs::read_to_string(path) {
|
match fs::read_to_string(path) {
|
||||||
Ok(contents) if !contents.trim().is_empty() => {
|
Ok(contents) if !contents.trim().is_empty() => {
|
||||||
|
|
@ -1145,6 +1148,7 @@ fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn write_settings_root(
|
fn write_settings_root(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
root: &serde_json::Map<String, serde_json::Value>,
|
root: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub mod bash_validation;
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
pub mod branch_lock;
|
pub mod branch_lock;
|
||||||
mod compact;
|
mod compact;
|
||||||
|
pub mod compact_pipeline;
|
||||||
mod config;
|
mod config;
|
||||||
pub mod config_validate;
|
pub mod config_validate;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
|
|
@ -64,6 +65,10 @@ pub use compact::{
|
||||||
compact_session, estimate_session_tokens, format_compact_summary,
|
compact_session, estimate_session_tokens, format_compact_summary,
|
||||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||||
};
|
};
|
||||||
|
pub use compact_pipeline::{
|
||||||
|
compact_session as compact_pipeline_session, CompactMessage,
|
||||||
|
CompactionConfig as PipelineCompactionConfig, CompactionStats, FileOpType, FileOperation,
|
||||||
|
};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||||
|
|
|
||||||
|
|
@ -731,7 +731,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(clippy::duration_suboptimal_units, clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn executable_decision_table_emits_retry_rebase_merge_escalate_cleanup_and_approval_events() {
|
fn executable_decision_table_emits_retry_rebase_merge_escalate_cleanup_and_approval_events() {
|
||||||
let engine = PolicyEngine::new(vec![
|
let engine = PolicyEngine::new(vec![
|
||||||
PolicyRule::new(
|
PolicyRule::new(
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Statistics from a Trident compaction run.
|
/// Statistics from a Trident compaction run.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub struct TridentStats {
|
pub struct TridentStats {
|
||||||
pub superseded_count: usize,
|
pub superseded_count: usize,
|
||||||
pub collapsed_chains: usize,
|
pub collapsed_chains: usize,
|
||||||
|
|
@ -41,21 +41,6 @@ pub struct TridentStats {
|
||||||
pub final_message_count: usize,
|
pub final_message_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TridentStats {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
superseded_count: 0,
|
|
||||||
collapsed_chains: 0,
|
|
||||||
messages_collapsed: 0,
|
|
||||||
clusters_found: 0,
|
|
||||||
messages_clustered: 0,
|
|
||||||
tokens_saved_estimate: 0,
|
|
||||||
original_message_count: 0,
|
|
||||||
final_message_count: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TridentStats {
|
impl TridentStats {
|
||||||
pub fn format_report(&self) -> String {
|
pub fn format_report(&self) -> String {
|
||||||
let compression = if self.final_message_count > 0 {
|
let compression = if self.final_message_count > 0 {
|
||||||
|
|
@ -192,7 +177,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||||
|
|
||||||
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
|
||||||
|
|
||||||
for (_path, ops) in &file_ops {
|
for ops in file_ops.values() {
|
||||||
if ops.len() < 2 {
|
if ops.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||||
|
|
||||||
if let Some(last_write) = last_write_idx {
|
if let Some(last_write) = last_write_idx {
|
||||||
for op in ops {
|
for op in ops {
|
||||||
if op.op_type == FileOp::Read && op.index < last_write {
|
if op.index < last_write {
|
||||||
obsolete_indices.insert(op.index);
|
|
||||||
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
|
|
||||||
&& op.index < last_write
|
|
||||||
{
|
|
||||||
obsolete_indices.insert(op.index);
|
obsolete_indices.insert(op.index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -325,7 +306,7 @@ fn stage2_collapse(
|
||||||
usage: None,
|
usage: None,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result.extend(buffer.drain(..));
|
result.append(&mut buffer);
|
||||||
}
|
}
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
result.push(msg.clone());
|
result.push(msg.clone());
|
||||||
|
|
@ -476,7 +457,7 @@ fn stage3_cluster(
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_clustered: usize = cluster_assignments.len();
|
let total_clustered: usize = cluster_assignments.len();
|
||||||
let clusters_found = cluster_id as usize;
|
let clusters_found = cluster_id;
|
||||||
|
|
||||||
let mut result: Vec<ConversationMessage> = Vec::new();
|
let mut result: Vec<ConversationMessage> = Vec::new();
|
||||||
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
|
@ -677,7 +658,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::compact::CompactionConfig;
|
use crate::compact::CompactionConfig;
|
||||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage1_removes_obsolete_file_reads() {
|
fn stage1_removes_obsolete_file_reads() {
|
||||||
|
|
@ -736,7 +717,7 @@ mod tests {
|
||||||
fn stage2_collapses_chatty_messages() {
|
fn stage2_collapses_chatty_messages() {
|
||||||
let mut messages = vec![];
|
let mut messages = vec![];
|
||||||
for i in 0..6 {
|
for i in 0..6 {
|
||||||
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
|
messages.push(ConversationMessage::user_text(format!("ok {i}")));
|
||||||
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||||
text: format!("got {i}"),
|
text: format!("got {i}"),
|
||||||
}]));
|
}]));
|
||||||
|
|
@ -767,9 +748,9 @@ mod tests {
|
||||||
},
|
},
|
||||||
]));
|
]));
|
||||||
messages.push(ConversationMessage::tool_result(
|
messages.push(ConversationMessage::tool_result(
|
||||||
&format!("read_{i}"),
|
format!("read_{i}"),
|
||||||
"read_file",
|
"read_file",
|
||||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1932,9 +1932,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
// Only reject for known top-level subcommands that don't use compact.
|
// Only reject for known top-level subcommands that don't use compact.
|
||||||
let first = rest[0].as_str();
|
let first = rest[0].as_str();
|
||||||
if is_known_top_level_subcommand(first) && first != "prompt" {
|
if is_known_top_level_subcommand(first) && first != "prompt" {
|
||||||
return Err(format!(
|
return Err(
|
||||||
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
|
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact".to_string()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3188,9 +3188,9 @@ fn parse_system_prompt_args(
|
||||||
})?;
|
})?;
|
||||||
// #99: validate --date is a plausible date string (no newlines, reasonable length)
|
// #99: validate --date is a plausible date string (no newlines, reasonable length)
|
||||||
if value.contains('\n') || value.contains('\r') {
|
if value.contains('\n') || value.contains('\r') {
|
||||||
return Err(format!(
|
return Err(
|
||||||
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
|
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".to_string()
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
if value.len() > 20 {
|
if value.len() > 20 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -3427,11 +3427,7 @@ impl DiagnosticCheck {
|
||||||
|
|
||||||
fn json_value(&self) -> Value {
|
fn json_value(&self) -> Value {
|
||||||
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
|
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
|
||||||
let id = self
|
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
|
||||||
.name
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.replace(' ', "_")
|
|
||||||
.replace('-', "_");
|
|
||||||
let mut value = Map::from_iter([
|
let mut value = Map::from_iter([
|
||||||
("id".to_string(), Value::String(id.clone())),
|
("id".to_string(), Value::String(id.clone())),
|
||||||
(
|
(
|
||||||
|
|
@ -6700,16 +6696,13 @@ fn run_resume_command(
|
||||||
}
|
}
|
||||||
SlashCommand::Plugins { action, target } => {
|
SlashCommand::Plugins { action, target } => {
|
||||||
// Only list is supported in resume mode (no runtime to reload)
|
// Only list is supported in resume mode (no runtime to reload)
|
||||||
match action.as_deref() {
|
if let Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) =
|
||||||
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
|
action.as_deref()
|
||||||
// #777: use interactive_only: prefix + \n hint so #776's classify/split
|
{
|
||||||
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
|
// #777: use interactive_only: prefix + \n hint so #776's classify/split
|
||||||
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
|
||||||
return Err(format!(
|
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
||||||
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
|
return Err(format!("interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command.").into());
|
||||||
).into());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let payload = plugins_command_payload_for(
|
let payload = plugins_command_payload_for(
|
||||||
|
|
@ -7821,8 +7814,7 @@ impl LiveCli {
|
||||||
let max_compact_rounds = 4;
|
let max_compact_rounds = 4;
|
||||||
let preserve_schedule = [4, 2, 1, 0];
|
let preserve_schedule = [4, 2, 1, 0];
|
||||||
|
|
||||||
for round in 0..max_compact_rounds {
|
for (round, &preserve) in preserve_schedule.iter().enumerate() {
|
||||||
let preserve = preserve_schedule[round];
|
|
||||||
println!(
|
println!(
|
||||||
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
||||||
round + 1,
|
round + 1,
|
||||||
|
|
@ -8574,8 +8566,8 @@ impl LiveCli {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
|
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
|
||||||
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
|
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
|
||||||
if action.as_deref() == Some("list") {
|
if action == Some("list") {
|
||||||
if let Some(filter) = target.as_deref() {
|
if let Some(filter) = target {
|
||||||
if filter.starts_with('-') {
|
if filter.starts_with('-') {
|
||||||
if matches!(output_format, CliOutputFormat::Json) {
|
if matches!(output_format, CliOutputFormat::Json) {
|
||||||
// ROADMAP #817: this is a handled local inventory parse error.
|
// ROADMAP #817: this is a handled local inventory parse error.
|
||||||
|
|
@ -9540,6 +9532,7 @@ fn print_status_snapshot(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn status_json_value(
|
fn status_json_value(
|
||||||
model: Option<&str>,
|
model: Option<&str>,
|
||||||
usage: StatusUsage,
|
usage: StatusUsage,
|
||||||
|
|
@ -10036,9 +10029,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||||
// (#731: "not supported on macOS" is a degraded state, not a hard error;
|
// (#731: "not supported on macOS" is a degraded state, not a hard error;
|
||||||
// filesystem_active:true means partial containment is working)
|
// filesystem_active:true means partial containment is working)
|
||||||
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
|
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
|
||||||
let top_status = if !status.enabled {
|
let top_status = if !status.enabled || status.active {
|
||||||
"ok"
|
|
||||||
} else if status.active {
|
|
||||||
"ok"
|
"ok"
|
||||||
} else if status.supported {
|
} else if status.supported {
|
||||||
"warn"
|
"warn"
|
||||||
|
|
@ -10404,10 +10395,7 @@ fn render_doctor_help_json() -> serde_json::Value {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #683-#692: extract structured metadata from help prose
|
type HelpMetadata = (
|
||||||
fn extract_help_metadata(
|
|
||||||
topic: LocalHelpTopic,
|
|
||||||
) -> (
|
|
||||||
Option<String>, // usage
|
Option<String>, // usage
|
||||||
Option<String>, // purpose
|
Option<String>, // purpose
|
||||||
Option<String>, // output description
|
Option<String>, // output description
|
||||||
|
|
@ -10416,7 +10404,10 @@ fn extract_help_metadata(
|
||||||
Option<Vec<String>>, // aliases
|
Option<Vec<String>>, // aliases
|
||||||
bool, // local_only
|
bool, // local_only
|
||||||
bool, // requires_credentials
|
bool, // requires_credentials
|
||||||
) {
|
);
|
||||||
|
|
||||||
|
/// #683-#692: extract structured metadata from help prose
|
||||||
|
fn extract_help_metadata(topic: LocalHelpTopic) -> HelpMetadata {
|
||||||
let text = render_help_topic(topic);
|
let text = render_help_topic(topic);
|
||||||
let mut usage = None;
|
let mut usage = None;
|
||||||
let mut purpose = None;
|
let mut purpose = None;
|
||||||
|
|
@ -13946,6 +13937,7 @@ fn permission_policy(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
|
#[allow(clippy::unnecessary_filter_map)]
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|message| {
|
.filter_map(|message| {
|
||||||
|
|
@ -16920,7 +16912,7 @@ mod tests {
|
||||||
for action in ["remove", "uninstall", "delete"] {
|
for action in ["remove", "uninstall", "delete"] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["skills".to_string(), action.to_string()])
|
parse_args(&["skills".to_string(), action.to_string()])
|
||||||
.expect(&format!("skills {action} should parse")),
|
.unwrap_or_else(|_| panic!("skills {action} should parse")),
|
||||||
CliAction::Skills {
|
CliAction::Skills {
|
||||||
args: Some(action.to_string()),
|
args: Some(action.to_string()),
|
||||||
output_format: CliOutputFormat::Text,
|
output_format: CliOutputFormat::Text,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![allow(unused_variables)]
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
|
|
@ -1007,13 +1009,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||||
assert!(
|
assert!(
|
||||||
!plugins
|
!plugins
|
||||||
.as_object()
|
.as_object()
|
||||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||||
"plugins list should not include reload_runtime"
|
"plugins list should not include reload_runtime"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!plugins
|
!plugins
|
||||||
.as_object()
|
.as_object()
|
||||||
.map_or(false, |o| o.contains_key("target")),
|
.is_some_and(|o| o.contains_key("target")),
|
||||||
"plugins list should not include target"
|
"plugins list should not include target"
|
||||||
);
|
);
|
||||||
// #703: structured summary replaces prose message
|
// #703: structured summary replaces prose message
|
||||||
|
|
@ -1706,13 +1708,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||||
assert!(
|
assert!(
|
||||||
!plugins
|
!plugins
|
||||||
.as_object()
|
.as_object()
|
||||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||||
"plugins list should not include reload_runtime"
|
"plugins list should not include reload_runtime"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!plugins
|
!plugins
|
||||||
.as_object()
|
.as_object()
|
||||||
.map_or(false, |o| o.contains_key("target")),
|
.is_some_and(|o| o.contains_key("target")),
|
||||||
"plugins list should not include target"
|
"plugins list should not include target"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -2945,7 +2947,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
|
||||||
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
|
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||||
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
|
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2983,9 +2985,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
parsed["hint"]
|
||||||
|| h.contains("medium")
|
.as_str()
|
||||||
|| h.contains("high")),
|
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||||
"hint must mention valid values (#756): {parsed}"
|
"hint must mention valid values (#756): {parsed}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3011,7 +3013,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||||
"missing --model value must be missing_flag_value (#756): {parsed2}"
|
"missing --model value must be missing_flag_value (#756): {parsed2}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||||
"missing --model hint must be non-empty (#756): {parsed2}"
|
"missing --model hint must be non-empty (#756): {parsed2}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3255,7 +3257,7 @@ fn short_p_flag_swallows_no_flags_755() {
|
||||||
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
|
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||||
"missing_prompt hint must be non-empty (#755)"
|
"missing_prompt hint must be non-empty (#755)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3397,7 +3399,7 @@ fn config_unsupported_section_json_hint_741() {
|
||||||
assert!(
|
assert!(
|
||||||
parsed["supported_sections"]
|
parsed["supported_sections"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.map_or(false, |a| !a.is_empty()),
|
.is_some_and(|a| !a.is_empty()),
|
||||||
"config {section} JSON must include supported_sections (#741)"
|
"config {section} JSON must include supported_sections (#741)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5366,8 +5368,10 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||||
.iter()
|
.iter()
|
||||||
.any(|agent| {
|
.any(|agent| {
|
||||||
agent["name"] == "my-agent"
|
agent["name"] == "my-agent"
|
||||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
&& fs::canonicalize(&agent_path)
|
||||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.expect("canonical listed agent path")
|
||||||
|
== agent["path"].as_str().expect("listed agent path")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue