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/sessions/
|
||||
.clawhip/
|
||||
.claw/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(clippy::result_large_err)]
|
||||
|
||||
mod client;
|
||||
mod error;
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// An approval scope defines what the token authorizes
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalScope {
|
||||
pub policy: String,
|
||||
pub action: String,
|
||||
pub repository: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub paths: Vec<String>,
|
||||
pub max_uses: u32,
|
||||
}
|
||||
|
||||
impl ApprovalScope {
|
||||
#[must_use]
|
||||
pub fn new(policy: impl Into<String>, action: impl Into<String>) -> Self {
|
||||
Self {
|
||||
policy: policy.into(),
|
||||
action: action.into(),
|
||||
repository: None,
|
||||
branch: None,
|
||||
paths: vec![],
|
||||
max_uses: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_repository(mut self, repository: impl Into<String>) -> Self {
|
||||
self.repository = Some(repository.into());
|
||||
pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
|
||||
self.repository = Some(repo.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
|
||||
self.branch = Some(branch.into());
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// A hop in the delegation chain
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalDelegationHop {
|
||||
pub actor: String,
|
||||
pub session_id: Option<String>,
|
||||
pub reason: String,
|
||||
pub session_id: Option<String>,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
impl ApprovalDelegationHop {
|
||||
#[must_use]
|
||||
pub fn new(actor: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
actor: actor.into(),
|
||||
session_id: None,
|
||||
reason: reason.into(),
|
||||
session_id: None,
|
||||
timestamp: current_timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
|
||||
self.session_id = Some(session_id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Current lifecycle state for a policy-exception approval token.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Status of an approval token
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ApprovalTokenStatus {
|
||||
Pending,
|
||||
Granted,
|
||||
Consumed,
|
||||
Expired,
|
||||
Revoked,
|
||||
Expired,
|
||||
Exhausted,
|
||||
}
|
||||
|
||||
impl ApprovalTokenStatus {
|
||||
#[must_use]
|
||||
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)]
|
||||
/// An approval token grant
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalTokenGrant {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub approved_executor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub expires_at_epoch_seconds: Option<u64>,
|
||||
pub max_uses: u32,
|
||||
pub uses: u32,
|
||||
delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
pub granted_at: u64,
|
||||
pub expires_at: Option<u64>,
|
||||
pub uses_remaining: u32,
|
||||
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenGrant {
|
||||
#[must_use]
|
||||
pub fn pending(
|
||||
token: impl Into<String>,
|
||||
scope: ApprovalScope,
|
||||
approving_actor: impl Into<String>,
|
||||
approved_executor: impl Into<String>,
|
||||
) -> Self {
|
||||
pub fn pending(scope: ApprovalScope) -> Self {
|
||||
let token = generate_token();
|
||||
let max_uses = scope.max_uses;
|
||||
Self {
|
||||
token: token.into(),
|
||||
token,
|
||||
scope,
|
||||
approving_actor: approving_actor.into(),
|
||||
approved_executor: approved_executor.into(),
|
||||
status: ApprovalTokenStatus::Pending,
|
||||
expires_at_epoch_seconds: None,
|
||||
max_uses: 1,
|
||||
uses: 0,
|
||||
delegation_chain: Vec::new(),
|
||||
granted_at: current_timestamp(),
|
||||
expires_at: None,
|
||||
uses_remaining: max_uses,
|
||||
delegation_chain: vec![],
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
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 {
|
||||
pub fn granted(mut self, approver: impl Into<String>, reason: &str) -> Self {
|
||||
self.status = ApprovalTokenStatus::Granted;
|
||||
self.delegation_chain
|
||||
.push(ApprovalDelegationHop::new(approver, reason));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expires_at(mut self, epoch_seconds: u64) -> Self {
|
||||
self.expires_at_epoch_seconds = Some(epoch_seconds);
|
||||
self
|
||||
pub fn approve(&mut self) {
|
||||
self.status = ApprovalTokenStatus::Granted;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_uses(mut self, max_uses: u32) -> Self {
|
||||
self.max_uses = max_uses.max(1);
|
||||
self
|
||||
pub fn revoke(&mut self) {
|
||||
self.status = ApprovalTokenStatus::Revoked;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_delegation_hop(mut self, hop: ApprovalDelegationHop) -> Self {
|
||||
pub fn set_expires_at(&mut self, epoch_seconds: u64) {
|
||||
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
|
||||
}
|
||||
|
||||
#[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] {
|
||||
&self.delegation_chain
|
||||
}
|
||||
}
|
||||
|
||||
/// Auditable result of verifying or consuming an approval token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// Audit log entry for token operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalTokenAudit {
|
||||
pub token: String,
|
||||
pub scope: ApprovalScope,
|
||||
pub approving_actor: String,
|
||||
pub executing_actor: String,
|
||||
pub status: ApprovalTokenStatus,
|
||||
pub delegated_execution: bool,
|
||||
pub delegation_chain: Vec<ApprovalDelegationHop>,
|
||||
pub uses: u32,
|
||||
pub max_uses: u32,
|
||||
pub action: TokenAuditAction,
|
||||
pub actor: String,
|
||||
pub timestamp: u64,
|
||||
pub details: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// In-memory approval-token ledger with one-time-use and replay protection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
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 {
|
||||
grants: BTreeMap<String, ApprovalTokenGrant>,
|
||||
tokens: HashMap<String, ApprovalTokenGrant>,
|
||||
audit_log: Vec<ApprovalTokenAudit>,
|
||||
}
|
||||
|
||||
impl ApprovalTokenLedger {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
Self {
|
||||
tokens: HashMap::new(),
|
||||
audit_log: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
self.grants.get(token)
|
||||
self.tokens.get(token)
|
||||
}
|
||||
|
||||
pub fn revoke(&mut self, token: &str) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
grant.status = ApprovalTokenStatus::Revoked;
|
||||
Ok(Self::audit_for(grant, &grant.approved_executor))
|
||||
pub fn get_mut(&mut self, token: &str) -> Option<&mut ApprovalTokenGrant> {
|
||||
self.tokens.get_mut(token)
|
||||
}
|
||||
|
||||
pub fn verify(
|
||||
&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(
|
||||
pub fn revoke(
|
||||
&mut self,
|
||||
token: &str,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
actor: &str,
|
||||
) -> Result<ApprovalTokenAudit, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.grants
|
||||
.tokens
|
||||
.get_mut(token)
|
||||
.ok_or(ApprovalTokenError::NoApproval)?;
|
||||
Self::validate_grant(grant, scope, executing_actor, now_epoch_seconds)?;
|
||||
grant.uses += 1;
|
||||
if grant.uses >= grant.max_uses {
|
||||
grant.status = ApprovalTokenStatus::Consumed;
|
||||
}
|
||||
Ok(Self::audit_for(grant, executing_actor))
|
||||
.ok_or(ApprovalTokenError::TokenNotFound)?;
|
||||
grant.revoke();
|
||||
let audit = ApprovalTokenAudit {
|
||||
token: token.to_string(),
|
||||
action: TokenAuditAction::Revoked,
|
||||
actor: actor.to_string(),
|
||||
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(
|
||||
grant: &ApprovalTokenGrant,
|
||||
scope: &ApprovalScope,
|
||||
executing_actor: &str,
|
||||
now_epoch_seconds: u64,
|
||||
) -> Result<(), ApprovalTokenError> {
|
||||
match grant.status {
|
||||
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 => {}
|
||||
pub fn verify(&self, token: &str) -> Result<&ApprovalTokenGrant, ApprovalTokenError> {
|
||||
let grant = self
|
||||
.tokens
|
||||
.get(token)
|
||||
.ok_or(ApprovalTokenError::TokenNotFound)?;
|
||||
if grant.status == ApprovalTokenStatus::Expired {
|
||||
return Err(ApprovalTokenError::TokenExpired);
|
||||
}
|
||||
|
||||
if grant
|
||||
.expires_at_epoch_seconds
|
||||
.is_some_and(|expires_at| now_epoch_seconds > expires_at)
|
||||
{
|
||||
return Err(ApprovalTokenError::ApprovalExpired);
|
||||
if grant.status == ApprovalTokenStatus::Exhausted {
|
||||
return Err(ApprovalTokenError::TokenExhausted);
|
||||
}
|
||||
|
||||
if grant.uses >= grant.max_uses {
|
||||
return Err(ApprovalTokenError::ApprovalAlreadyConsumed);
|
||||
if grant.status == ApprovalTokenStatus::Revoked {
|
||||
return Err(ApprovalTokenError::TokenRevoked);
|
||||
}
|
||||
|
||||
if grant.scope != *scope {
|
||||
return Err(ApprovalTokenError::ScopeMismatch {
|
||||
expected: Box::new(grant.scope.clone()),
|
||||
actual: Box::new(scope.clone()),
|
||||
});
|
||||
if grant.status != ApprovalTokenStatus::Granted {
|
||||
return Err(ApprovalTokenError::InvalidToken);
|
||||
}
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
if grant.approved_executor != executing_actor {
|
||||
return Err(ApprovalTokenError::UnauthorizedDelegate {
|
||||
expected: grant.approved_executor.clone(),
|
||||
actual: executing_actor.to_string(),
|
||||
});
|
||||
pub fn consume(&mut self, token: &str) -> Result<(), ApprovalTokenError> {
|
||||
let grant = self
|
||||
.tokens
|
||||
.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(())
|
||||
}
|
||||
|
||||
fn audit_for(grant: &ApprovalTokenGrant, executing_actor: &str) -> ApprovalTokenAudit {
|
||||
let mut delegation_chain = grant.delegation_chain.clone();
|
||||
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 audit_log(&self) -> &[ApprovalTokenAudit] {
|
||||
&self.audit_log
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ApprovalDelegationHop, ApprovalScope, ApprovalTokenError, ApprovalTokenGrant,
|
||||
ApprovalTokenLedger, ApprovalTokenStatus,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn approval_token_blocks_until_owner_grants_policy_exception() {
|
||||
fn test_token_lifecycle() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("main_push_forbidden", "git push")
|
||||
.with_repository("sisyphus/claw-code")
|
||||
.with_branch("main");
|
||||
ledger.insert(ApprovalTokenGrant::pending(
|
||||
"tok-pending",
|
||||
scope.clone(),
|
||||
"repo-owner",
|
||||
"release-bot",
|
||||
));
|
||||
|
||||
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
|
||||
.granted("admin", "test approval");
|
||||
let token = grant.token.clone();
|
||||
ledger.insert(grant);
|
||||
assert!(ledger.verify(&token).is_ok());
|
||||
assert!(ledger.consume(&token).is_ok());
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-missing", &scope, "release-bot", 10),
|
||||
Err(ApprovalTokenError::NoApproval)
|
||||
));
|
||||
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)
|
||||
ledger.verify(&token),
|
||||
Err(ApprovalTokenError::TokenExhausted)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_token_preserves_delegation_traceability() {
|
||||
fn test_token_revocation() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let scope = ApprovalScope::new("deploy_requires_owner", "deploy prod");
|
||||
ledger.insert(
|
||||
ApprovalTokenGrant::granted("tok-delegated", scope.clone(), "owner", "deploy-bot")
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("owner", "owner approval")
|
||||
.with_session_id("session-owner"),
|
||||
)
|
||||
.with_delegation_hop(
|
||||
ApprovalDelegationHop::new("lead-agent", "handoff to deploy bot")
|
||||
.with_session_id("session-lead"),
|
||||
),
|
||||
);
|
||||
|
||||
let grant = ApprovalTokenGrant::pending(ApprovalScope::new("test_policy", "test_action"))
|
||||
.granted("admin", "test approval");
|
||||
let token = grant.token.clone();
|
||||
ledger.insert(grant);
|
||||
assert!(ledger.revoke(&token, "admin").is_ok());
|
||||
assert!(matches!(
|
||||
ledger.verify("tok-delegated", &scope, "unexpected-bot", 10),
|
||||
Err(ApprovalTokenError::UnauthorizedDelegate { expected, actual })
|
||||
if expected == "deploy-bot" && actual == "unexpected-bot"
|
||||
ledger.verify(&token),
|
||||
Err(ApprovalTokenError::TokenRevoked)
|
||||
));
|
||||
}
|
||||
|
||||
let audit = ledger
|
||||
.consume("tok-delegated", &scope, "deploy-bot", 10)
|
||||
.expect("approved delegate should consume token");
|
||||
let actors = audit
|
||||
.delegation_chain
|
||||
.iter()
|
||||
.map(|hop| hop.actor.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
#[test]
|
||||
fn test_delegation_chain() {
|
||||
let mut grant = ApprovalTokenGrant::pending(ApprovalScope::new("policy", "action"))
|
||||
.granted("admin", "initial approval");
|
||||
grant.add_delegation_hop(ApprovalDelegationHop::new(
|
||||
"delegate",
|
||||
"delegated authority",
|
||||
));
|
||||
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);
|
||||
assert_eq!(actors, vec!["owner", "lead-agent", "deploy-bot"]);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[0].session_id.as_deref(),
|
||||
Some("session-owner")
|
||||
);
|
||||
assert_eq!(
|
||||
audit.delegation_chain[1].session_id.as_deref(),
|
||||
Some("session-lead")
|
||||
);
|
||||
#[test]
|
||||
fn test_multi_use_token() {
|
||||
let mut ledger = ApprovalTokenLedger::new();
|
||||
let mut grant =
|
||||
ApprovalTokenGrant::pending(ApprovalScope::new("test", "action").with_max_uses(3))
|
||||
.granted("admin", "multi-use");
|
||||
grant.set_max_uses(3);
|
||||
let token = grant.token.clone();
|
||||
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`.
|
||||
/// Creates the file and directory if they don't exist. Sets file permissions
|
||||
/// to `0o600` (owner read/write only) to protect stored API keys.
|
||||
#[allow(dead_code)]
|
||||
pub fn save_user_provider_settings(
|
||||
kind: &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`.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
||||
let config_home = default_config_home();
|
||||
let settings_path = config_home.join("settings.json");
|
||||
|
|
@ -1133,6 +1135,7 @@ pub fn clear_user_provider_settings() -> Result<(), ConfigError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_settings_root(path: &Path) -> serde_json::Map<String, serde_json::Value> {
|
||||
match fs::read_to_string(path) {
|
||||
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(
|
||||
path: &Path,
|
||||
root: &serde_json::Map<String, serde_json::Value>,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub mod bash_validation;
|
|||
mod bootstrap;
|
||||
pub mod branch_lock;
|
||||
mod compact;
|
||||
pub mod compact_pipeline;
|
||||
mod config;
|
||||
pub mod config_validate;
|
||||
mod conversation;
|
||||
|
|
@ -64,6 +65,10 @@ pub use compact::{
|
|||
compact_session, estimate_session_tokens, format_compact_summary,
|
||||
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::{
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||
|
|
|
|||
|
|
@ -731,7 +731,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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() {
|
||||
let engine = PolicyEngine::new(vec![
|
||||
PolicyRule::new(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ impl Default for TridentConfig {
|
|||
}
|
||||
|
||||
/// Statistics from a Trident compaction run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TridentStats {
|
||||
pub superseded_count: usize,
|
||||
pub collapsed_chains: usize,
|
||||
|
|
@ -41,21 +41,6 @@ pub struct TridentStats {
|
|||
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 {
|
||||
pub fn format_report(&self) -> String {
|
||||
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();
|
||||
|
||||
for (_path, ops) in &file_ops {
|
||||
for ops in file_ops.values() {
|
||||
if ops.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -205,11 +190,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
|||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.op_type == FileOp::Read && 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
|
||||
{
|
||||
if op.index < last_write {
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
}
|
||||
|
|
@ -325,7 +306,7 @@ fn stage2_collapse(
|
|||
usage: None,
|
||||
});
|
||||
} else {
|
||||
result.extend(buffer.drain(..));
|
||||
result.append(&mut buffer);
|
||||
}
|
||||
buffer.clear();
|
||||
result.push(msg.clone());
|
||||
|
|
@ -476,7 +457,7 @@ fn stage3_cluster(
|
|||
}
|
||||
|
||||
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 cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||
|
|
@ -677,7 +658,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
|
||||
#[test]
|
||||
fn stage1_removes_obsolete_file_reads() {
|
||||
|
|
@ -736,7 +717,7 @@ mod tests {
|
|||
fn stage2_collapses_chatty_messages() {
|
||||
let mut messages = vec![];
|
||||
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 {
|
||||
text: format!("got {i}"),
|
||||
}]));
|
||||
|
|
@ -767,9 +748,9 @@ mod tests {
|
|||
},
|
||||
]));
|
||||
messages.push(ConversationMessage::tool_result(
|
||||
&format!("read_{i}"),
|
||||
format!("read_{i}"),
|
||||
"read_file",
|
||||
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
|
||||
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.
|
||||
let first = rest[0].as_str();
|
||||
if is_known_top_level_subcommand(first) && first != "prompt" {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"<prompt>\" or echo \"<prompt>\" | claw --compact"
|
||||
));
|
||||
return Err(
|
||||
"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)
|
||||
if value.contains('\n') || value.contains('\r') {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>"
|
||||
));
|
||||
return Err(
|
||||
"invalid_flag_value: --date value contains invalid characters.\nUsage: --date <YYYY-MM-DD>".to_string()
|
||||
);
|
||||
}
|
||||
if value.len() > 20 {
|
||||
return Err(format!(
|
||||
|
|
@ -3427,11 +3427,7 @@ impl DiagnosticCheck {
|
|||
|
||||
fn json_value(&self) -> Value {
|
||||
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
|
||||
let id = self
|
||||
.name
|
||||
.to_ascii_lowercase()
|
||||
.replace(' ', "_")
|
||||
.replace('-', "_");
|
||||
let id = self.name.to_ascii_lowercase().replace([' ', '-'], "_");
|
||||
let mut value = Map::from_iter([
|
||||
("id".to_string(), Value::String(id.clone())),
|
||||
(
|
||||
|
|
@ -6700,16 +6696,13 @@ fn run_resume_command(
|
|||
}
|
||||
SlashCommand::Plugins { action, target } => {
|
||||
// Only list is supported in resume mode (no runtime to reload)
|
||||
match action.as_deref() {
|
||||
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
|
||||
// #777: use interactive_only: prefix + \n hint so #776's classify/split
|
||||
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
|
||||
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
||||
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());
|
||||
}
|
||||
_ => {}
|
||||
if let 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.
|
||||
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
|
||||
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());
|
||||
}
|
||||
let cwd = env::current_dir()?;
|
||||
let payload = plugins_command_payload_for(
|
||||
|
|
@ -7821,8 +7814,7 @@ impl LiveCli {
|
|||
let max_compact_rounds = 4;
|
||||
let preserve_schedule = [4, 2, 1, 0];
|
||||
|
||||
for round in 0..max_compact_rounds {
|
||||
let preserve = preserve_schedule[round];
|
||||
for (round, &preserve) in preserve_schedule.iter().enumerate() {
|
||||
println!(
|
||||
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
||||
round + 1,
|
||||
|
|
@ -8574,8 +8566,8 @@ impl LiveCli {
|
|||
let cwd = env::current_dir()?;
|
||||
// #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.
|
||||
if action.as_deref() == Some("list") {
|
||||
if let Some(filter) = target.as_deref() {
|
||||
if action == Some("list") {
|
||||
if let Some(filter) = target {
|
||||
if filter.starts_with('-') {
|
||||
if matches!(output_format, CliOutputFormat::Json) {
|
||||
// ROADMAP #817: this is a handled local inventory parse error.
|
||||
|
|
@ -9540,6 +9532,7 @@ fn print_status_snapshot(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn status_json_value(
|
||||
model: Option<&str>,
|
||||
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;
|
||||
// filesystem_active:true means partial containment is working)
|
||||
// error = enabled but unsupported AND no filesystem sandbox either (nothing active)
|
||||
let top_status = if !status.enabled {
|
||||
"ok"
|
||||
} else if status.active {
|
||||
let top_status = if !status.enabled || status.active {
|
||||
"ok"
|
||||
} else if status.supported {
|
||||
"warn"
|
||||
|
|
@ -10404,10 +10395,7 @@ fn render_doctor_help_json() -> serde_json::Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// #683-#692: extract structured metadata from help prose
|
||||
fn extract_help_metadata(
|
||||
topic: LocalHelpTopic,
|
||||
) -> (
|
||||
type HelpMetadata = (
|
||||
Option<String>, // usage
|
||||
Option<String>, // purpose
|
||||
Option<String>, // output description
|
||||
|
|
@ -10416,7 +10404,10 @@ fn extract_help_metadata(
|
|||
Option<Vec<String>>, // aliases
|
||||
bool, // local_only
|
||||
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 mut usage = None;
|
||||
let mut purpose = None;
|
||||
|
|
@ -13946,6 +13937,7 @@ fn permission_policy(
|
|||
}
|
||||
|
||||
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||
#[allow(clippy::unnecessary_filter_map)]
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
|
|
@ -16920,7 +16912,7 @@ mod tests {
|
|||
for action in ["remove", "uninstall", "delete"] {
|
||||
assert_eq!(
|
||||
parse_args(&["skills".to_string(), action.to_string()])
|
||||
.expect(&format!("skills {action} should parse")),
|
||||
.unwrap_or_else(|_| panic!("skills {action} should parse")),
|
||||
CliAction::Skills {
|
||||
args: Some(action.to_string()),
|
||||
output_format: CliOutputFormat::Text,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![allow(unused_variables)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
|
@ -1007,13 +1009,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||
assert!(
|
||||
!plugins
|
||||
.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"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
// #703: structured summary replaces prose message
|
||||
|
|
@ -1706,13 +1708,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||
assert!(
|
||||
!plugins
|
||||
.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"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
|
@ -2983,9 +2985,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
|||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
||||
|| h.contains("medium")
|
||||
|| h.contains("high")),
|
||||
parsed["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||
"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}"
|
||||
);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
|
@ -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}"
|
||||
);
|
||||
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)"
|
||||
);
|
||||
}
|
||||
|
|
@ -3397,7 +3399,7 @@ fn config_unsupported_section_json_hint_741() {
|
|||
assert!(
|
||||
parsed["supported_sections"]
|
||||
.as_array()
|
||||
.map_or(false, |a| !a.is_empty()),
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
"config {section} JSON must include supported_sections (#741)"
|
||||
);
|
||||
}
|
||||
|
|
@ -5366,8 +5368,10 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
|||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
&& fs::canonicalize(&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